Summary
Popover was never brought up to the standard its closest sibling HoverCard got in #438 (the Popper → Floating UI port). popover_controller.js is untouched since the monorepo unification (#358), while hover_card_controller.js gained state attributes, side-aware positioning, timer cleanup and keyboard dismissal.
The result is that PopoverContent ships eight Tailwind variant classes that can never match, plus a timer that can outlive the controller.
1. data-state is never set — the enter/exit animation classes are dead CSS
gem/lib/ruby_ui/popover/popover_content.rb declares:
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0","data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
But popover_controller.js only toggles the hidden class:
showPopover(){this.contentTarget.classList.remove("hidden");this.updatePosition();}hidePopover(){this.contentTarget.classList.add("hidden");if(this.cleanup){this.cleanup();}}Nothing ever writes data-state on the content element, and unlike HoverCardContent (which server-renders data: { ..., state: :closed }), PopoverContent doesn't even carry an initial value. So the attribute simply never exists and the popover appears/disappears with no transition — visually inconsistent with HoverCard, DropdownMenu and Tooltip, which all animate.
hover_card_controller.js is the reference:
show(){this.openValue=true;this.contentTarget.classList.remove("hidden");this.contentTarget.dataset.state="open";// ...}hide(){this.openValue=false;this.contentTarget.classList.add("hidden");this.contentTarget.dataset.state="closed";// ...}2. data-side is never set — the directional slide classes are dead CSS too
Same root cause, one layer further. popover_content.rb also declares:
"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",
updatePosition() destructures only { x, y } from computePosition and throws away the resolved placement:
computePosition(this.triggerTarget,this.contentTarget,{placement: this.optionsValue.placement||"bottom",middleware: [flip(),shift(),offset(8)],}).then(({ x, y })=>{Object.assign(this.contentTarget.style,{left: `${x}px`,top: `${y}px`});});The resolved placement matters here precisely because flip() is in the middleware chain — the popover can end up on the opposite side of the requested one, and only the resolved value is correct. hover_card_controller.js already does this:
}).then(({ x, y, placement })=>{Object.assign(this.contentTarget.style,{left: `${x}px`,top: `${y}px`});this.contentTarget.dataset.side=placement.split("-")[0];});3. closeTimeout survives disconnect()
handleMouseLeave schedules a 100 ms close:
handleMouseLeave=()=>{this.closeTimeout=setTimeout(()=>{this.openValue=false;this.hidePopover();},100);};disconnect() never clears it:
disconnect(){this.removeEventListeners();if(this.cleanup){this.cleanup();}}If the element is disconnected inside that 100 ms window and reconnected — a Turbo frame or stream replacement, a morph, an element moved in the DOM — Stimulus builds a fresh controller instance while the old instance's timeout is still pending. The stale callback resolves this.contentTarget to the same, re-attached element and hides it, so a popover the new instance has just opened snaps shut on its own. There is no console error to go on; the symptom is a popover that closes for no visible reason.
hover_card_controller.js clears its timers on disconnect (clearTimers()), and also nulls out this.cleanup after invoking it, which the popover doesn't — so a stale autoUpdate cleanup stays referenced.
4. No keyboard dismissal
HoverCard and DropdownMenu both close on Escape. Popover has no keydown handling at all, so with options: { trigger: "click" } a keyboard user can open the popover and has no way to dismiss it — the only close paths are an outside mouse click or a second click on the trigger.
Proposed fix
Bring Popover to parity with HoverCard, mirroring its implementation rather than inventing a new approach:
- server-render
data-state="closed" on PopoverContent, and set dataset.state to open/closed in showPopover/hidePopover; - read
placement back out of computePosition and set dataset.side from it; - clear
closeTimeout in disconnect(), and null this.cleanup after calling it; - close on
Escape, with the document keydown listener added on open and removed on close/disconnect.
Both copies of the controller (gem/lib/ruby_ui/popover/popover_controller.js and docs/app/javascript/controllers/ruby_ui/popover_controller.js) need the change — they are currently byte-identical and drifting them apart is what #490 had to fix for Accordion.
Known limitation, deliberately out of scope
Because hidden (display: none) is applied in the same frame as data-state="closed", the animate-out exit animation still won't be visible — the element is removed from the render tree before the animation can run. HoverCard and DropdownMenu have the identical limitation; only Tooltip handles it, via an animationend listener that defers unmounting. Making the exit animation actually play is a broader change across all the hidden-toggling floating components and should be its own issue, so this one stops at the enter animation, the side-aware slide, and the correctness bugs.
Summary
Popoverwas never brought up to the standard its closest siblingHoverCardgot in #438 (the Popper → Floating UI port).popover_controller.jsis untouched since the monorepo unification (#358), whilehover_card_controller.jsgained state attributes, side-aware positioning, timer cleanup and keyboard dismissal.The result is that
PopoverContentships eight Tailwind variant classes that can never match, plus a timer that can outlive the controller.1.
data-stateis never set — the enter/exit animation classes are dead CSSgem/lib/ruby_ui/popover/popover_content.rbdeclares:But
popover_controller.jsonly toggles thehiddenclass:Nothing ever writes
data-stateon the content element, and unlikeHoverCardContent(which server-rendersdata: { ..., state: :closed }),PopoverContentdoesn't even carry an initial value. So the attribute simply never exists and the popover appears/disappears with no transition — visually inconsistent withHoverCard,DropdownMenuandTooltip, which all animate.hover_card_controller.jsis the reference:2.
data-sideis never set — the directional slide classes are dead CSS tooSame root cause, one layer further.
popover_content.rbalso declares:updatePosition()destructures only{ x, y }fromcomputePositionand throws away the resolvedplacement:The resolved placement matters here precisely because
flip()is in the middleware chain — the popover can end up on the opposite side of the requested one, and only the resolved value is correct.hover_card_controller.jsalready does this:3.
closeTimeoutsurvivesdisconnect()handleMouseLeaveschedules a 100 ms close:disconnect()never clears it:If the element is disconnected inside that 100 ms window and reconnected — a Turbo frame or stream replacement, a morph, an element moved in the DOM — Stimulus builds a fresh controller instance while the old instance's timeout is still pending. The stale callback resolves
this.contentTargetto the same, re-attached element and hides it, so a popover the new instance has just opened snaps shut on its own. There is no console error to go on; the symptom is a popover that closes for no visible reason.hover_card_controller.jsclears its timers on disconnect (clearTimers()), and also nulls outthis.cleanupafter invoking it, which the popover doesn't — so a staleautoUpdatecleanup stays referenced.4. No keyboard dismissal
HoverCardandDropdownMenuboth close onEscape.Popoverhas no keydown handling at all, so withoptions: { trigger: "click" }a keyboard user can open the popover and has no way to dismiss it — the only close paths are an outside mouse click or a second click on the trigger.Proposed fix
Bring
Popoverto parity withHoverCard, mirroring its implementation rather than inventing a new approach:data-state="closed"onPopoverContent, and setdataset.statetoopen/closedinshowPopover/hidePopover;placementback out ofcomputePositionand setdataset.sidefrom it;closeTimeoutindisconnect(), and nullthis.cleanupafter calling it;Escape, with thedocumentkeydown listener added on open and removed on close/disconnect.Both copies of the controller (
gem/lib/ruby_ui/popover/popover_controller.jsanddocs/app/javascript/controllers/ruby_ui/popover_controller.js) need the change — they are currently byte-identical and drifting them apart is what #490 had to fix forAccordion.Known limitation, deliberately out of scope
Because
hidden(display: none) is applied in the same frame asdata-state="closed", theanimate-outexit animation still won't be visible — the element is removed from the render tree before the animation can run.HoverCardandDropdownMenuhave the identical limitation; onlyTooltiphandles it, via ananimationendlistener that defers unmounting. Making the exit animation actually play is a broader change across all thehidden-toggling floating components and should be its own issue, so this one stops at the enter animation, the side-aware slide, and the correctness bugs.