Skip to content

Commit 3ebfbc5

Browse files
tvqclaudedjalmaaraujo
authored
[Bug Fix] Overlays: play the exit animation before hiding (#506)
* [Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before 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> * Only settle on the exit animation, and settle on disconnect 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> * Match the exit run against every computed animation name 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> * Settle the exit on getAnimations(), and keep disconnect from failing getComputedStyle reports an element's own `display` and still resolves `animation-name: exit` under a display:none ancestor, so the guard armed a listener for an animation with no box to run in, and `hidden` never landed. getAnimations() returns nothing for that element — as it does for `animation: none` and for an element already hidden — so all of those settle synchronously. HoverCard's disconnect() started with removeEventListeners(), which resolves both targets unguarded; once one is gone, Stimulus swallows the throw and the settle at the end never runs. Release what is held outside the element first, guard each target on its own, and apply the same order to hide(), which runs from a timer. ContextMenu's hide() touched the content target before releasing its document listeners, and disconnect() goes through it — same reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Play the exit animation in every overlay with animate-out DropdownMenu, ClipboardPopover, Select, Sheet and CommandDialog all carried data-[state=closed]:animate-out and cut it the same way: `hidden` or element.remove() in the frame the state changed. DropdownMenu, Clipboard and Select never set data-state at all, and Select keyed its exit on the root's open value instead. The settle block now takes the animated element and hands the outcome to a per-controller afterExit(): `hidden` on the wrapper for the floating overlays, element.remove() for Sheet and CommandDialog. The animated element is an explicit `panel` target (plus `backdrop` where there is one), and the captured exit names live in a WeakMap per element because Clipboard can run two exits at once. The block itself is byte-identical in all eight controllers. DropdownMenu#toggle reads openValue, since `hidden` now lands after the exit and no longer tells the states apart; its z-index is released on settle so the menu fades above its siblings. CommandDialog reopened while dismissing brings the same instance back through show() rather than stacking a second one. Clipboard's empty-source branch called a method that did not exist (showErrorPopover) — fixed while restructuring it. Every affected content component gets data-[state=closed]:fill-mode-forwards so the last frame holds until the hide lands, with a rendering test each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(mcp): rebuild registry.json for the overlay exit-animation changes --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Djalma Araújo <djalma@nossomos.cc>
1 parent 3ae5ad1 commit 3ebfbc5

26 files changed

Lines changed: 556 additions & 63 deletions

‎gem/lib/ruby_ui/clipboard/clipboard_controller.js‎

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ import { computePosition, flip, shift } from "@floating-ui/dom";
33

44
// Connects to data-controller="accordion"
55
export default class extends Controller {
6-
static targets = ['trigger', 'source', 'successPopover', 'errorPopover']
6+
static targets = ['trigger', 'source', 'successPopover', 'successPanel', 'errorPopover', 'errorPanel']
77
static values = {
88
options: {
99
type: Object,
1010
default: {},
1111
},
1212
}
1313

14+
disconnect() {
15+
// Nothing is left to wait for the exit animation, so apply the pending hide now.
16+
if (this.hasSuccessPanelTarget) this.settleExit(this.successPanelTarget);
17+
if (this.hasErrorPanelTarget) this.settleExit(this.errorPanelTarget);
18+
}
19+
1420
copy() {
1521
let sourceElement = this.sourceTarget.children[0];
1622
if (!sourceElement) {
17-
this.showErrorPopover();
23+
this.#showErrorPopover();
1824
return;
1925
}
2026
let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText;
@@ -26,8 +32,8 @@ export default class extends Controller {
2632
}
2733

2834
onClickOutside() {
29-
if (!this.successPopoverTarget.classList.contains("hidden")) this.successPopoverTarget.classList.add("hidden");
30-
if (!this.errorPopoverTarget.classList.contains("hidden")) this.errorPopoverTarget.classList.add("hidden");
35+
this.#hidePopover(this.successPopoverTarget, this.successPanelTarget);
36+
this.#hidePopover(this.errorPopoverTarget, this.errorPanelTarget);
3137
}
3238

3339
#computeTooltip(popoverElement) {
@@ -43,12 +49,65 @@ export default class extends Controller {
4349
}
4450

4551
#showSuccessPopover() {
46-
this.#computeTooltip(this.successPopoverTarget);
47-
this.successPopoverTarget.classList.remove("hidden");
52+
this.#showPopover(this.successPopoverTarget, this.successPanelTarget);
4853
}
4954

5055
#showErrorPopover() {
51-
this.#computeTooltip(this.errorPopoverTarget);
52-
this.errorPopoverTarget.classList.remove("hidden");
56+
this.#showPopover(this.errorPopoverTarget, this.errorPanelTarget);
57+
}
58+
59+
#showPopover(popover, panel) {
60+
this.#computeTooltip(popover);
61+
popover.classList.remove("hidden");
62+
panel.dataset.state = "open";
63+
}
64+
65+
#hidePopover(popover, panel) {
66+
if (popover.classList.contains("hidden")) return;
67+
68+
panel.dataset.state = "closed";
69+
this.hideAfterExitAnimation(panel);
70+
}
71+
72+
afterExit(panel) {
73+
const popover = panel === this.successPanelTarget ? this.successPopoverTarget : this.errorPopoverTarget;
74+
popover.classList.add("hidden");
75+
}
76+
77+
// Overlay exit — the same block in every overlay controller, so keep them in sync.
78+
exitAnimationNames = new WeakMap();
79+
80+
hideAfterExitAnimation(animated) {
81+
const exitAnimations = animated
82+
.getAnimations()
83+
.filter((animation) => animation instanceof CSSAnimation);
84+
85+
// No exit animation, or no box to run it in: animationend would never fire.
86+
if (exitAnimations.length === 0) {
87+
this.settleExit(animated);
88+
return;
89+
}
90+
91+
this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
92+
animated.addEventListener("animationend", this.handleExitAnimationEnd);
93+
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
94+
}
95+
96+
handleExitAnimationEnd = (event) => {
97+
// animationend bubbles — an animated child must not hide its container.
98+
if (event.target !== event.currentTarget) return;
99+
// Closing mid-open cancels the enter animation; only the exit run settles this.
100+
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;
101+
102+
this.settleExit(event.currentTarget);
103+
};
104+
105+
settleExit(animated) {
106+
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
107+
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
108+
// Reopened mid-exit: it is on its way back in, leave it visible.
109+
if (animated.dataset.state !== "closed") return;
110+
111+
this.afterExit(animated);
53112
}
54113
}

‎gem/lib/ruby_ui/clipboard/clipboard_popover.rb‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,22 @@ def clipboard_target
2828
end
2929
end
3030

31+
def panel_target
32+
case @type
33+
when :success
34+
"successPanel"
35+
when :error
36+
"errorPanel"
37+
end
38+
end
39+
3140
def default_attrs
3241
{
3342
data: {
34-
state: :open
43+
state: :open,
44+
ruby_ui__clipboard_target: panel_target
3545
},
36-
class: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 text-foreground shadow-md outline-none 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 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"
46+
class: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 text-foreground shadow-md outline-none 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 data-[state=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"
3747
}
3848
end
3949
end

‎gem/lib/ruby_ui/command/command_controller.js‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Fuse from "fuse.js";
33

44
// Connects to data-controller="ruby-ui--command"
55
export default class extends Controller {
6-
static targets = ["input", "group", "item", "empty"];
6+
static targets = ["input", "group", "item", "empty", "backdrop", "panel"];
77

88
connect() {
99
this.selectedIndex = -1;
@@ -17,13 +17,67 @@ export default class extends Controller {
1717
this.toggleVisibility(this.emptyTargets, false);
1818
}
1919

20+
disconnect() {
21+
// Nothing is left to wait for the exit animation, so apply the pending removal now.
22+
if (this.hasPanelTarget) this.settleExit(this.panelTarget);
23+
}
24+
2025
dismiss() {
21-
// allow scroll on body
26+
this.backdropTarget.dataset.state = "closed";
27+
this.panelTarget.dataset.state = "closed";
28+
this.hideAfterExitAnimation(this.panelTarget);
29+
}
30+
31+
// Opened again while dismissing: bring this instance back instead of stacking a new one.
32+
show() {
33+
this.backdropTarget.dataset.state = "open";
34+
this.panelTarget.dataset.state = "open";
35+
document.body.classList.add("overflow-hidden");
36+
this.focusInput();
37+
}
38+
39+
afterExit() {
2240
document.body.classList.remove("overflow-hidden");
23-
// remove the element
2441
this.element.remove();
2542
}
2643

44+
// Overlay exit — the same block in every overlay controller, so keep them in sync.
45+
exitAnimationNames = new WeakMap();
46+
47+
hideAfterExitAnimation(animated) {
48+
const exitAnimations = animated
49+
.getAnimations()
50+
.filter((animation) => animation instanceof CSSAnimation);
51+
52+
// No exit animation, or no box to run it in: animationend would never fire.
53+
if (exitAnimations.length === 0) {
54+
this.settleExit(animated);
55+
return;
56+
}
57+
58+
this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
59+
animated.addEventListener("animationend", this.handleExitAnimationEnd);
60+
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
61+
}
62+
63+
handleExitAnimationEnd = (event) => {
64+
// animationend bubbles — an animated child must not hide its container.
65+
if (event.target !== event.currentTarget) return;
66+
// Closing mid-open cancels the enter animation; only the exit run settles this.
67+
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;
68+
69+
this.settleExit(event.currentTarget);
70+
};
71+
72+
settleExit(animated) {
73+
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
74+
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
75+
// Reopened mid-exit: it is on its way back in, leave it visible.
76+
if (animated.dataset.state !== "closed") return;
77+
78+
this.afterExit(animated);
79+
}
80+
2781
focusInput() {
2882
this.inputTarget?.focus();
2983
}

‎gem/lib/ruby_ui/command/command_dialog_content.rb‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ def view_template(&block)
3030
def default_attrs
3131
{
3232
data_state: "open",
33+
data_ruby_ui__command_target: "panel",
3334
class: [
34-
"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
35+
"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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 data-[state=closed]:fill-mode-forwards data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
3536
SIZES[@size]
3637
]
3738
}
@@ -41,7 +42,8 @@ def backdrop
4142
div(
4243
data_state: "open",
4344
data_action: "click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss",
44-
class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
45+
data_ruby_ui__command_target: "backdrop",
46+
class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm 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]:fill-mode-forwards"
4547
)
4648
end
4749
end

‎gem/lib/ruby_ui/command/command_dialog_controller.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default class extends Controller {
2323
}
2424

2525
if (this.openOutlet) {
26-
this.openOutlet.focusInput();
26+
this.openOutlet.show();
2727
return;
2828
}
2929

‎gem/lib/ruby_ui/context_menu/context_menu_content.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def default_attrs
1515
data_state: "closed",
1616
data: {ruby_ui__context_menu_target: "content"},
1717
class:
18-
"hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md 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 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",
18+
"hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md 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 data-[state=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",
1919
tabindex: "-1",
2020
data_orientation: "vertical"
2121
}

‎gem/lib/ruby_ui/context_menu/context_menu_controller.js‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export default class extends Controller {
2424

2525
disconnect() {
2626
this.hide();
27+
// Nothing is left to wait for the exit animation, so apply the pending hide now.
28+
if (this.hasContentTarget) this.settleExit(this.contentTarget);
2729
}
2830

2931
handleContextMenu(event) {
@@ -49,14 +51,58 @@ export default class extends Controller {
4951
hide() {
5052
if (!this.openValue) return;
5153
this.openValue = false;
52-
this.contentTarget.classList.add("hidden");
53-
this.contentTarget.dataset.state = "closed";
5454
this.removeEventListeners();
5555
this.deselectAll();
5656
if (this.cleanup) {
5757
this.cleanup();
5858
this.cleanup = null;
5959
}
60+
61+
if (!this.hasContentTarget) return;
62+
63+
this.contentTarget.dataset.state = "closed";
64+
this.hideAfterExitAnimation(this.contentTarget);
65+
}
66+
67+
afterExit(content) {
68+
content.classList.add("hidden");
69+
}
70+
71+
// Overlay exit — the same block in every overlay controller, so keep them in sync.
72+
exitAnimationNames = new WeakMap();
73+
74+
hideAfterExitAnimation(animated) {
75+
const exitAnimations = animated
76+
.getAnimations()
77+
.filter((animation) => animation instanceof CSSAnimation);
78+
79+
// No exit animation, or no box to run it in: animationend would never fire.
80+
if (exitAnimations.length === 0) {
81+
this.settleExit(animated);
82+
return;
83+
}
84+
85+
this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));
86+
animated.addEventListener("animationend", this.handleExitAnimationEnd);
87+
animated.addEventListener("animationcancel", this.handleExitAnimationEnd);
88+
}
89+
90+
handleExitAnimationEnd = (event) => {
91+
// animationend bubbles — an animated child must not hide its container.
92+
if (event.target !== event.currentTarget) return;
93+
// Closing mid-open cancels the enter animation; only the exit run settles this.
94+
if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;
95+
96+
this.settleExit(event.currentTarget);
97+
};
98+
99+
settleExit(animated) {
100+
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
101+
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
102+
// Reopened mid-exit: it is on its way back in, leave it visible.
103+
if (animated.dataset.state !== "closed") return;
104+
105+
this.afterExit(animated);
60106
}
61107

62108
updatePosition() {

‎gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ def view_template(&block)
1313
def default_attrs
1414
{
1515
data: {
16-
state: :open
16+
state: :open,
17+
ruby_ui__dropdown_menu_target: "panel"
1718
},
18-
class: "z-50 min-w-[8rem] rounded-md border bg-background p-1 text-foreground shadow-md 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 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 w-56"
19+
class: "z-50 min-w-[8rem] rounded-md border bg-background p-1 text-foreground shadow-md 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 data-[state=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 w-56"
1920
}
2021
end
2122

0 commit comments

Comments
 (0)