[Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before hiding - #506
[Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before hiding#506tvq wants to merge 3 commits into
Conversation
…fore hiding All three set data-state="closed" and add `hidden` (display: none) in the same frame, so data-[state=closed]:animate-out never gets one. Defer `hidden` to animationend/animationcancel, and add fill-mode-forwards so the last frame holds until it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="gem/lib/ruby_ui/context_menu/context_menu_controller.js">
<violation number="1" location="gem/lib/ruby_ui/context_menu/context_menu_controller.js:67">
P2: When `prefers-reduced-motion: reduce` is active, this controller still waits for the `animate-out` event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name `none`, so the menu hides immediately.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| const styles = getComputedStyle(content); | ||
|
|
||
| // An element with no exit animation never fires animationend. | ||
| if (styles.animationName === "none" || styles.display === "none") { |
There was a problem hiding this comment.
P2: When prefers-reduced-motion: reduce is active, this controller still waits for the animate-out event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name none, so the menu hides immediately.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/context_menu/context_menu_controller.js, line 67:
<comment>When `prefers-reduced-motion: reduce` is active, this controller still waits for the `animate-out` event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name `none`, so the menu hides immediately.</comment>
<file context>
@@ -59,6 +59,36 @@ export default class extends Controller {
+ const styles = getComputedStyle(content);
+
+ // An element with no exit animation never fires animationend.
+ if (styles.animationName === "none" || styles.display === "none") {
+ this.hideUnlessReopened(content);
+ return;
</file context>
| if (styles.animationName === "none" || styles.display === "none") { | |
| if ( | |
| styles.animationName === "none" || | |
| styles.display === "none" || | |
| window.matchMedia("(prefers-reduced-motion: reduce)").matches | |
| ) { |
There was a problem hiding this comment.
Checked this one before acting on it, and I'd rather not take the suggestion — but the premise is right.
grep -c prefers-reduced-motion on the docs app's built application.css returns 0: the library ships no reduced-motion rules at all, so tw-animate-css animations run regardless of the setting. Where I think the conclusion differs is the consequence — the exit animation still runs and animationend still fires, so nothing hangs. Reduced-motion users see the same exit they already see on enter.
Adding matchMedia only here would make the close jump while the open still animates, which reads worse than the current consistency. And reduced-motion support looks library-wide to me — every animate-in in the overlay family, not just these three exits — so I'd rather not smuggle a partial version in under a bug fix. I've noted it in the PR description and am happy to open a separate issue for it.
The computed animation-name check earns its place for the cases it does cover: no tw-animate-css installed, overridden classes, or a consumer's own reduced-motion CSS.
Closing while the opening animation runs cancels `enter`, and that animationcancel reached the handler as if the exit had finished. Capture the exit animation-name when arming and ignore events from any other run. disconnect() left the handlers attached, unlike every other listener in these controllers; ContextMenu's disconnect armed them on an element it was about to drop. It now applies the pending hide instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
animation-name is comma-separated when the content carries more than one animation, while each event names a single run, so the equality check rejected them all and the element never hid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Related issue
No existing issue — happy to open one if you'd prefer to track it separately.
Description
PopoverContent,HoverCardContentandContextMenuContentall shipdata-[state=closed]:animate-out fade-out-0 zoom-out-95, but those classes never get a frame. Each controller sets the closed state and applieshidden(display: none) in the same tick:So the enter animation runs and the exit is a hard cut. This finishes what #495 started for the open direction — same argument, other half.
The fix defers
hiddento the end of the exit animation, in one block that is byte-identical across the three controllers:data-state="closed"first,hiddenonanimationend— the classes now get their frames.animationcanceltoo — reopening mid-exit must not strand the pending hide.animationendbubbles, so an animated child must not hide its container; and closing during the opening animation cancelsenter, so only the captured exit run settles it. Both mirror@radix-ui/react-presence.animation-nameis read up front. If there is no exit animation to wait for (notw-animate-css, overridden classes, or a consumer's own reduced-motion rule) the element hides at once instead of hanging on an event that never fires.settleExiton disconnect — every other listener in these controllers is torn down there, andContextMenu#disconnectcallshide(), which would otherwise arm handlers on an element it is dropping.data-[state=closed]:fill-mode-forwardson the three content components — without it the element repaints at full opacity between the last keyframe andhidden, which flashes.TooltipContentalready carries this class.Kept as three copies rather than a shared module, matching how the eight overlay controllers each carry their own Floating UI plumbing — and because the generator installs components standalone, so a shared import would need a new dependency kind and has to resolve under importmap too. Happy to extract it instead if you'd rather.
Follow-ups
If this approach looks right, I'll send the same treatment for the rest in separate PRs:
DropdownMenu,ClipboardPopover— same, plusdata-statewhich the controllers never setSheet,CommandDialog—element.remove()outright, so they need the state and the waitTooltipis already correct (it is the pattern this follows) andToasthas its own working variant. That would leave every overlay in the library animating out.Testing instructions
The difference is motion, so a still screenshot shows nothing — the steps below are the check.
cd gem && bundle exec rakecovers the renderedfill-mode-forwardsclass; the lifecycle itself is JS:cd docs && bin/dev/docs/popover— open, then close via outside click and via Esc. It should fade + zoom out, not vanish./docs/hover_card— hover on, hover off./docs/context_menu— right click, then dismiss.Note on reduced motion: the library ships no
prefers-reduced-motionrules today, soanimate-in/animate-outrun regardless — this PR neither improves nor regresses that. Happy to open a separate issue if library-wide reduced-motion support is wanted.