Hover Card
For sighted users to preview content available behind a link.
Usage
Example
HoverCard do
HoverCardTrigger do
Button(variant: :link) { "@joeldrapper" } # Make this a link in order to navigate somewhere
end
HoverCardContent do
div(class: "flex justify-between space-x-4") do
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
div(class: "space-y-1") do
h4(class: "text-sm font-medium") { "@joeldrapper" }
p(class: "text-sm") do
"Creator of Phlex Components. Ruby on Rails developer."
end
div(class: "flex items-center pt-2") do
svg(
width: "15",
height: "15",
viewbox: "0 0 15 15",
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
class: "mr-2 h-4 w-4 opacity-70"
) do |s|
s.path(
d:
"M4.5 1C4.77614 1 5 1.22386 5 1.5V2H10V1.5C10 1.22386 10.2239 1 10.5 1C10.7761 1 11 1.22386 11 1.5V2H12.5C13.3284 2 14 2.67157 14 3.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V3.5C1 2.67157 1.67157 2 2.5 2H4V1.5C4 1.22386 4.22386 1 4.5 1ZM10 3V3.5C10 3.77614 10.2239 4 10.5 4C10.7761 4 11 3.77614 11 3.5V3H12.5C12.7761 3 13 3.22386 13 3.5V5H2V3.5C2 3.22386 2.22386 3 2.5 3H4V3.5C4 3.77614 4.22386 4 4.5 4C4.77614 4 5 3.77614 5 3.5V3H10ZM2 6V12.5C2 12.7761 2.22386 13 2.5 13H12.5C12.7761 13 13 12.7761 13 12.5V6H2ZM7 7.5C7 7.22386 7.22386 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22386 8 7 7.77614 7 7.5ZM9.5 7C9.22386 7 9 7.22386 9 7.5C9 7.77614 9.22386 8 9.5 8C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7ZM11 7.5C11 7.22386 11.2239 7 11.5 7C11.7761 7 12 7.22386 12 7.5C12 7.77614 11.7761 8 11.5 8C11.2239 8 11 7.77614 11 7.5ZM11.5 9C11.2239 9 11 9.22386 11 9.5C11 9.77614 11.2239 10 11.5 10C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9ZM9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5C10 9.77614 9.77614 10 9.5 10C9.22386 10 9 9.77614 9 9.5ZM7.5 9C7.22386 9 7 9.22386 7 9.5C7 9.77614 7.22386 10 7.5 10C7.77614 10 8 9.77614 8 9.5C8 9.22386 7.77614 9 7.5 9ZM5 9.5C5 9.22386 5.22386 9 5.5 9C5.77614 9 6 9.22386 6 9.5C6 9.77614 5.77614 10 5.5 10C5.22386 10 5 9.77614 5 9.5ZM3.5 9C3.22386 9 3 9.22386 3 9.5C3 9.77614 3.22386 10 3.5 10C3.77614 10 4 9.77614 4 9.5C4 9.22386 3.77614 9 3.5 9ZM3 11.5C3 11.2239 3.22386 11 3.5 11C3.77614 11 4 11.2239 4 11.5C4 11.7761 3.77614 12 3.5 12C3.22386 12 3 11.7761 3 11.5ZM5.5 11C5.22386 11 5 11.2239 5 11.5C5 11.7761 5.22386 12 5.5 12C5.77614 12 6 11.7761 6 11.5C6 11.2239 5.77614 11 5.5 11ZM7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5ZM9.5 11C9.22386 11 9 11.2239 9 11.5C9 11.7761 9.22386 12 9.5 12C9.77614 12 10 11.7761 10 11.5C10 11.2239 9.77614 11 9.5 11Z",
fill: "currentColor",
fill_rule: "evenodd",
clip_rule: "evenodd"
)
end
span(class: "text-xs text-muted-foreground") { "Joined December 2021" }
end
end
end
end
end
Side and delay
side: and align: place the card around the trigger. side_offset: is the gap between them, and align_offset: shifts the card along the trigger's edge. delay: is how long the pointer rests on the trigger before the card opens, and close_delay: how long after it leaves before the card closes, in milliseconds (600 and 300 by default).
HoverCard do
HoverCardTrigger(delay: 100, close_delay: 100) do
Button(variant: :outline) { "Top" }
end
HoverCardContent(side: :top, align: :start) do
div(class: "flex flex-col gap-2") do
h4(class: "font-medium") { "Hover Card" }
p { "This hover card appears on the top side of the trigger." }
end
end
end
Inside a clipping container
The card opens in the top layer, so a container that hides its overflow (a scroll area, a table cell, a truncating container) doesn't clip it.
div(class: "relative h-24 w-64 overflow-hidden rounded-md border p-4") do
HoverCard do
HoverCardTrigger do
Button(variant: :link) { "@joeldrapper" }
end
HoverCardContent do
p { "Not clipped by the container above." }
end
end
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component HoverCard
Manual installation
1
Add RubyUI::HoverCard to app/components/ruby_ui/hover_card/hover_card.rb
# frozen_string_literal: true
module RubyUI
# Root of the hover card: scopes the trigger and the card for the Stimulus
# controller. open: renders it open on load (shadcn's defaultOpen).
#
# option: is the older API and still works as a fallback: placement: when
# HoverCardContent gets no side:/align:, delay: ([open, close] or a number)
# when HoverCardTrigger gets no delay:/close_delay:. strategy: is ignored:
# the card opens in the top layer, so no ancestor clips it.
class HoverCard < Base
def initialize(open: false, option: {}, **attrs)
@open = open
@options = option
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "hover-card",
controller: "ruby-ui--hover-card",
ruby_ui__hover_card_open_value: @open.to_s,
ruby_ui__hover_card_options_value: @options.to_json
}
}
end
end
end
2
Add RubyUI::HoverCardContent to app/components/ruby_ui/hover_card/hover_card_content.rb
# frozen_string_literal: true
module RubyUI
# The card. A native popover (top layer, so no ancestor clips it) placed
# next to the trigger by Floating UI. side:/align: pick the placement,
# side_offset:/align_offset: the gap, with the defaults of shadcn's
# HoverCardContent. Without side:/align:, HoverCard's option placement:
# applies, then bottom/center.
class HoverCardContent < Base
SIDES = %i[top right bottom left].freeze
ALIGNS = %i[start center end].freeze
def initialize(side: nil, side_offset: 4, align: nil, align_offset: 4, **attrs)
@side = SIDES.include?(side&.to_sym) ? side.to_sym : nil
@align = ALIGNS.include?(align&.to_sym) ? align.to_sym : nil
@side_offset = side_offset
@align_offset = align_offset
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
popover: "manual",
data: {
slot: "hover-card-content",
ruby_ui__hover_card_target: "content",
# Kept in sync with data-open/data-closed for CSS that targets data-[state=...].
state: "closed",
closed: "",
preferred_side: @side,
preferred_align: @align,
side_offset: @side_offset,
align_offset: @align_offset
},
class: [
"fixed inset-auto m-0 border-0 z-50 w-64 origin-(--transform-origin) outline-hidden",
"bg-popover text-popover-foreground ring-1 ring-foreground/10 rounded-lg p-4 text-sm shadow-md duration-100",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:fill-mode-forwards",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
]
}
end
end
end
3
Add RubyUI::HoverCardDocs to app/components/ruby_ui/hover_card/hover_card_docs.rb
# frozen_string_literal: true
class Views::Docs::HoverCard < Views::Base
def view_template
component = "HoverCard"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Hover Card", description: "For sighted users to preview content available behind a link.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
HoverCard do
HoverCardTrigger do
Button(variant: :link) { "@joeldrapper" } # Make this a link in order to navigate somewhere
end
HoverCardContent do
div(class: "flex justify-between space-x-4") do
Avatar do
AvatarImage(src: "https://avatars.githubusercontent.com/u/246692?v=4", alt: "joeldrapper")
AvatarFallback { "JD" }
end
div(class: "space-y-1") do
h4(class: "text-sm font-medium") { "@joeldrapper" }
p(class: "text-sm") do
"Creator of Phlex Components. Ruby on Rails developer."
end
div(class: "flex items-center pt-2") do
svg(
width: "15",
height: "15",
viewbox: "0 0 15 15",
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
class: "mr-2 h-4 w-4 opacity-70"
) do |s|
s.path(
d:
"M4.5 1C4.77614 1 5 1.22386 5 1.5V2H10V1.5C10 1.22386 10.2239 1 10.5 1C10.7761 1 11 1.22386 11 1.5V2H12.5C13.3284 2 14 2.67157 14 3.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V3.5C1 2.67157 1.67157 2 2.5 2H4V1.5C4 1.22386 4.22386 1 4.5 1ZM10 3V3.5C10 3.77614 10.2239 4 10.5 4C10.7761 4 11 3.77614 11 3.5V3H12.5C12.7761 3 13 3.22386 13 3.5V5H2V3.5C2 3.22386 2.22386 3 2.5 3H4V3.5C4 3.77614 4.22386 4 4.5 4C4.77614 4 5 3.77614 5 3.5V3H10ZM2 6V12.5C2 12.7761 2.22386 13 2.5 13H12.5C12.7761 13 13 12.7761 13 12.5V6H2ZM7 7.5C7 7.22386 7.22386 7 7.5 7C7.77614 7 8 7.22386 8 7.5C8 7.77614 7.77614 8 7.5 8C7.22386 8 7 7.77614 7 7.5ZM9.5 7C9.22386 7 9 7.22386 9 7.5C9 7.77614 9.22386 8 9.5 8C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7ZM11 7.5C11 7.22386 11.2239 7 11.5 7C11.7761 7 12 7.22386 12 7.5C12 7.77614 11.7761 8 11.5 8C11.2239 8 11 7.77614 11 7.5ZM11.5 9C11.2239 9 11 9.22386 11 9.5C11 9.77614 11.2239 10 11.5 10C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9ZM9 9.5C9 9.22386 9.22386 9 9.5 9C9.77614 9 10 9.22386 10 9.5C10 9.77614 9.77614 10 9.5 10C9.22386 10 9 9.77614 9 9.5ZM7.5 9C7.22386 9 7 9.22386 7 9.5C7 9.77614 7.22386 10 7.5 10C7.77614 10 8 9.77614 8 9.5C8 9.22386 7.77614 9 7.5 9ZM5 9.5C5 9.22386 5.22386 9 5.5 9C5.77614 9 6 9.22386 6 9.5C6 9.77614 5.77614 10 5.5 10C5.22386 10 5 9.77614 5 9.5ZM3.5 9C3.22386 9 3 9.22386 3 9.5C3 9.77614 3.22386 10 3.5 10C3.77614 10 4 9.77614 4 9.5C4 9.22386 3.77614 9 3.5 9ZM3 11.5C3 11.2239 3.22386 11 3.5 11C3.77614 11 4 11.2239 4 11.5C4 11.7761 3.77614 12 3.5 12C3.22386 12 3 11.7761 3 11.5ZM5.5 11C5.22386 11 5 11.2239 5 11.5C5 11.7761 5.22386 12 5.5 12C5.77614 12 6 11.7761 6 11.5C6 11.2239 5.77614 11 5.5 11ZM7 11.5C7 11.2239 7.22386 11 7.5 11C7.77614 11 8 11.2239 8 11.5C8 11.7761 7.77614 12 7.5 12C7.22386 12 7 11.7761 7 11.5ZM9.5 11C9.22386 11 9 11.2239 9 11.5C9 11.7761 9.22386 12 9.5 12C9.77614 12 10 11.7761 10 11.5C10 11.2239 9.77614 11 9.5 11Z",
fill: "currentColor",
fill_rule: "evenodd",
clip_rule: "evenodd"
)
end
span(class: "text-xs text-muted-foreground") { "Joined December 2021" }
end
end
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Side and delay", description: "side: and align: place the card around the trigger. side_offset: is the gap between them, and align_offset: shifts the card along the trigger's edge. delay: is how long the pointer rests on the trigger before the card opens, and close_delay: how long after it leaves before the card closes, in milliseconds (600 and 300 by default).", context: self) do
<<~RUBY
HoverCard do
HoverCardTrigger(delay: 100, close_delay: 100) do
Button(variant: :outline) { "Top" }
end
HoverCardContent(side: :top, align: :start) do
div(class: "flex flex-col gap-2") do
h4(class: "font-medium") { "Hover Card" }
p { "This hover card appears on the top side of the trigger." }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Inside a clipping container", description: "The card opens in the top layer, so a container that hides its overflow (a scroll area, a table cell, a truncating container) doesn't clip it.", context: self) do
<<~RUBY
div(class: "relative h-24 w-64 overflow-hidden rounded-md border p-4") do
HoverCard do
HoverCardTrigger do
Button(variant: :link) { "@joeldrapper" }
end
HoverCardContent do
p { "Not clipped by the container above." }
end
end
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
private
# def components
# [
# Docs::ComponentStruct.new(name: "PopoverController", source: "https://github.com/PhlexUI/phlex_ui_stimulus/blob/main/controllers/popover_controller.js", built_using: :stimulus),
# Docs::ComponentStruct.new(name: "HoverCard", source: "https://github.com/PhlexUI/phlex_ui/blob/main/lib/phlex_ui/hover_card.rb", built_using: :phlex),
# Docs::ComponentStruct.new(name: "HoverCardTrigger", source: "https://github.com/PhlexUI/phlex_ui/blob/main/lib/phlex_ui/hover_card/trigger.rb", built_using: :phlex),
# Docs::ComponentStruct.new(name: "HoverCardContent", source: "https://github.com/PhlexUI/phlex_ui/blob/main/lib/phlex_ui/hover_card/content.rb", built_using: :phlex)
# ]
# end
end
4
Add RubyUI::HoverCardTrigger to app/components/ruby_ui/hover_card/hover_card_trigger.rb
# frozen_string_literal: true
module RubyUI
# Wraps the element that opens the card on hover or keyboard focus.
# delay:/close_delay: are in milliseconds, with the defaults of shadcn's
# HoverCardTrigger (Base UI: 600 and 300).
class HoverCardTrigger < Base
def initialize(delay: nil, close_delay: nil, **attrs)
@delay = delay
@close_delay = close_delay
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "hover-card-trigger",
ruby_ui__hover_card_target: "trigger",
delay: @delay,
close_delay: @close_delay
},
class: "inline-block"
}
end
end
end
5
Add hover_card_controller.js to app/javascript/controllers/ruby_ui/hover_card_controller.js
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset, autoUpdate } from "@floating-ui/dom";
// Connects to data-controller="ruby-ui--hover-card"
//
// Behaves like Base UI's PreviewCard: hovering or focusing the trigger opens
// the card after a delay, moving the pointer onto the card keeps it open, and
// leaving both (or Escape) closes it after the close delay. The card is a
// native popover="manual", so it opens in the top layer and no ancestor with
// overflow: hidden clips it; Floating UI places it next to the trigger.
const SIDES = ["top", "right", "bottom", "left"];
const ALIGNS = ["start", "center", "end"];
export default class extends Controller {
static targets = ["trigger", "content"];
static values = {
open: { type: Boolean, default: false },
options: { type: Object, default: {} },
// make content width match the trigger element (true/false)
matchWidth: { type: Boolean, default: false },
};
// The card while open: teardown uses it even if the content target has
// left the DOM (e.g. a Turbo Stream replaced it).
openContent = null;
connect() {
document.addEventListener("turbo:before-cache", this.handleBeforeCache);
if (this.openValue) this.show();
this.ready = true;
}
// hide() only touches openContent, never a target, so it can't throw here.
disconnect() {
this.ready = false;
document.removeEventListener("turbo:before-cache", this.handleBeforeCache);
// Nothing is left to wait for the exit animation, so close at once.
this.hide({ animate: false });
}
// Target callbacks run before connect() and again whenever Turbo or other
// code swaps the trigger or the card inside a connected hover card.
triggerTargetConnected(trigger) {
this.listen(trigger);
}
// Floating UI would keep placing the card against the detached trigger.
triggerTargetDisconnected(trigger) {
this.unlisten(trigger);
if (this.isOpen) this.hide({ animate: false });
}
contentTargetConnected(content) {
this.listen(content);
// A Turbo restore can bring back an open card: settle on closed.
if (content !== this.openContent) {
content.removeAttribute("data-open");
this.settleExit(content);
}
}
// The open card was removed on its own: release the listeners and Floating UI.
contentTargetDisconnected(content) {
this.unlisten(content);
if (content === this.openContent) {
this.teardown();
this.openValue = false;
}
}
// Changing the open value from outside opens or closes the card.
openValueChanged(open) {
if (!this.ready) return;
this.clearTimers();
open ? this.show() : this.hide();
}
// --- Delays -----------------------------------------------------------------
// HoverCardTrigger delay:/close_delay: win; then HoverCard option delay:
// (a number or an [open, close] tuple); then Base UI's 600/300.
get openDelay() {
return this.delayFor("delay", 0, 600);
}
get closeDelay() {
return this.delayFor("closeDelay", 1, 300);
}
delayFor(key, index, fallback) {
const own = this.hasTriggerTarget ? this.triggerTarget.dataset[key] : undefined;
if (own !== undefined && own !== "") return Number(own);
const delay = this.optionsValue.delay;
if (Array.isArray(delay)) return delay[index] ?? fallback;
if (typeof delay === "number") return delay;
return fallback;
}
// --- Listeners ----------------------------------------------------------------
listen(element) {
element.addEventListener("mouseenter", this.handleEnter);
element.addEventListener("mouseleave", this.handleLeave);
element.addEventListener("focusin", this.handleEnter);
element.addEventListener("focusout", this.handleLeave);
}
unlisten(element) {
element.removeEventListener("mouseenter", this.handleEnter);
element.removeEventListener("mouseleave", this.handleLeave);
element.removeEventListener("focusin", this.handleEnter);
element.removeEventListener("focusout", this.handleLeave);
}
handleEnter = () => {
this.clearTimers();
if (this.isOpen) return;
this.openTimeout = setTimeout(() => this.show(), this.openDelay);
};
handleLeave = () => {
this.clearTimers();
if (!this.isOpen) return;
this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay);
};
handleKeydown = (event) => {
if (event.key === "Escape") this.hide();
};
handleBeforeCache = () => {
this.clearTimers();
if (this.isOpen) this.hide({ animate: false });
};
clearTimers() {
clearTimeout(this.openTimeout);
clearTimeout(this.closeTimeout);
}
// --- Opening and closing ------------------------------------------------------
get isOpen() {
return this.openContent !== null;
}
show() {
if (!this.hasContentTarget || !this.hasTriggerTarget || this.isOpen) return;
const content = (this.openContent = this.contentTarget);
this.openValue = true;
// Side and origin before the enter animation starts; position() refines both.
const { side, align } = this.preferredPlacement(content);
content.dataset.side = side;
content.dataset.align = align;
content.removeAttribute("data-closed");
content.setAttribute("data-open", "");
content.dataset.state = "open";
if (this.matchWidthValue) content.style.width = `${this.triggerTarget.offsetWidth}px`;
if (!content.matches(":popover-open")) content.showPopover();
document.addEventListener("keydown", this.handleKeydown);
this.startPositioning();
}
hide({ animate = true } = {}) {
const content = this.openContent;
this.teardown();
if (!content) return;
this.openValue = false;
content.removeAttribute("data-open");
content.setAttribute("data-closed", "");
content.dataset.state = "closed";
animate ? this.hideAfterExitAnimation(content) : this.settleExit(content);
}
// Everything show() started: timers, the Escape listener, Floating UI.
teardown() {
this.clearTimers();
document.removeEventListener("keydown", this.handleKeydown);
this.stopPositioning();
this.openContent = null;
}
// --- Positioning --------------------------------------------------------------
// HoverCardContent side:/align:, else HoverCard option placement:, else bottom/center.
preferredPlacement(content) {
const [optionSide, optionAlign] = String(this.optionsValue.placement || "").split("-");
const side = [content.dataset.preferredSide, optionSide].find((value) => SIDES.includes(value)) || "bottom";
const align = content.dataset.preferredSide
? content.dataset.preferredAlign || "center"
: [content.dataset.preferredAlign, optionAlign].find((value) => ALIGNS.includes(value)) || "center";
return { side, align };
}
startPositioning() {
this.stopPositioning();
const trigger = this.triggerTarget;
const content = this.openContent;
this.cleanup = autoUpdate(trigger, content, () => this.position(trigger, content));
}
stopPositioning() {
if (this.cleanup) this.cleanup();
this.cleanup = null;
}
position(trigger, content) {
const { side, align } = this.preferredPlacement(content);
const placement = align === "center" ? side : `${side}-${align}`;
const sideOffset = Number(content.dataset.sideOffset ?? 4);
const alignOffset = Number(content.dataset.alignOffset ?? 4);
computePosition(trigger, content, {
strategy: "fixed",
placement,
middleware: [
// alignmentAxis flips the offset for align: :end, as Base UI does.
offset({ mainAxis: sideOffset, crossAxis: alignOffset, alignmentAxis: alignOffset }),
flip(),
shift({ padding: 5 }),
],
}).then(({ x, y, placement: resolved }) => {
if (!content.isConnected || content !== this.openContent) return;
const [resolvedSide, resolvedAlign = "center"] = resolved.split("-");
Object.assign(content.style, { left: `${x}px`, top: `${y}px` });
content.dataset.side = resolvedSide;
content.dataset.align = resolvedAlign;
content.style.setProperty("--transform-origin", this.transformOrigin(trigger, content, resolvedSide, x, y, sideOffset));
});
}
// The card grows out of the trigger: the origin sits on the trigger's
// center line, at the gap between the two (Base UI's --transform-origin).
transformOrigin(trigger, content, side, x, y, sideOffset) {
const anchor = trigger.getBoundingClientRect();
const { width, height } = content.getBoundingClientRect();
const centerX = anchor.left + anchor.width / 2 - x;
const centerY = anchor.top + anchor.height / 2 - y;
switch (side) {
case "top":
return `${centerX}px ${height + sideOffset}px`;
case "left":
return `${width + sideOffset}px ${centerY}px`;
case "right":
return `${-sideOffset}px ${centerY}px`;
default:
return `${centerX}px ${-sideOffset}px`;
}
}
// --- Exit animation (same block as the other overlay controllers) ------------
exitAnimationNames = new WeakMap();
hideAfterExitAnimation(animated) {
const exitAnimations = animated.getAnimations().filter((animation) => animation instanceof CSSAnimation);
// No exit animation, or no box to run it in: animationend would never fire.
if (exitAnimations.length === 0) {
this.settleExit(animated);
return;
}
this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
animated.addEventListener("animationend", this.handleExitAnimationEnd);
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
}
handleExitAnimationEnd = (event) => {
// animationend bubbles — an animated child must not hide its container.
if (event.target !== event.currentTarget) return;
// Closing mid-open cancels the enter animation; only the exit run settles this.
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;
this.settleExit(event.currentTarget);
};
settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
// Reopened mid-exit: it is on its way back in, leave it showing.
if (animated.hasAttribute("data-open")) return;
animated.setAttribute("data-closed", "");
animated.dataset.state = "closed";
if (animated.matches(":popover-open")) animated.hidePopover();
}
}
6
Update the Stimulus controllers manifest file
rake stimulus:manifest:update7
Install @floating-ui/dom Javascript dependency
// with yarn
yarn add @floating-ui/dom
// with npm
npm install @floating-ui/dom
// with importmaps
bin/importmap pin @floating-ui/dom
Components
| Component | Built using | Source |
|---|---|---|
HoverCard | Phlex | |
HoverCardContent | Phlex | |
HoverCardDocs | Phlex | |
HoverCardTrigger | Phlex | |
HoverCardController | Stimulus JS |