Skip to content

Commit 58243b0

Browse files
authored
[Improvement] Add exit animation, remove delay, lazy-mount Tooltip content (#393)
* [Improvement] Add exit animation, remove delay, lazy-mount Tooltip content Aligns the Tooltip with shadcn/ui's defaults and behavior: - Switch from `data-show="true"` (binary) to `data-state="open"|"closed"`, enabling clean enter/exit animations driven by paired `data-[state=open]:*` / `data-[state=closed]:*` Tailwind classes. - Add `data-[state=closed]:fill-mode-forwards` so the exit animation persists at its final keyframe (opacity 0) — without it, `tw-animate-css` defaults to `fill-mode: none` and the content snaps back to opacity 1 after the animation completes. - Remove the 500ms delay — shadcn overrides Radix's 700ms default with `delayDuration: 0`, so the tooltip now opens instantly on hover/focus. - Lazy-mount the content via `<template>`: the content lives inert in a `DocumentFragment` until first hover; the controller clones it into `document.body` on `show`, unmounts on the close animation's `animationend`. Avoids paying parse + CSS + Stimulus target scan cost for tooltips the user never interacts with. - Add `turbo:before-cache` listener so the tooltip is removed from `body` before Turbo snapshots the page, keeping the cache clean. - Wire the trigger through Stimulus actions (`mouseenter` / `mouseleave` / `focus` / `blur`) on a single `data-action`, instead of relying on CSS sibling selectors (`peer-hover` / `peer-focus`). - Drop `class: "peer"` from the trigger — the sibling pattern is no longer used. * [Refactor] Break Tooltip class and data-action strings into arrays Improves readability by grouping related Tailwind variants (placement, state) and separating each Stimulus action descriptor on its own line.
1 parent 1c1666a commit 58243b0

4 files changed

Lines changed: 138 additions & 52 deletions

File tree

docs/app/javascript/controllers/ruby_ui/tooltip_controller.js

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
33

44
export default class extends Controller {
55
static targets = ["trigger", "content"];
6-
static values = { placement: String }
76

8-
constructor(...args) {
9-
super(...args);
10-
this.cleanup;
7+
static values = { placement: "top" };
8+
9+
mount() {
10+
if (this.mounted) return;
11+
12+
const element = this.cloneTemplate();
13+
element.setAttribute("data-placement", this.placementValue);
14+
document.body.appendChild(element);
15+
16+
this.triggerTarget.setAttribute("aria-describedby", element.id);
17+
element.addEventListener("animationend", (event) => this.animationEnd(event));
18+
19+
const onBeforeCache = () => this.unmount();
20+
document.addEventListener("turbo:before-cache", onBeforeCache);
21+
22+
this.mounted = { element, onBeforeCache };
23+
this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition());
1124
}
1225

13-
connect() {
14-
this.setFloatingElement();
26+
unmount() {
27+
if (!this.mounted) return;
1528

16-
const tooltipId = this.contentTarget.getAttribute("id");
17-
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
29+
document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache);
1830

31+
this.mounted.stopAutoUpdate?.();
32+
this.mounted.element.remove();
33+
this.triggerTarget.removeAttribute("aria-describedby");
34+
35+
this.mounted = null;
1936
}
2037

2138
disconnect() {
22-
this.cleanup();
23-
}
24-
25-
setFloatingElement() {
26-
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
27-
computePosition(this.triggerTarget, this.contentTarget, {
28-
placement: this.placementValue,
29-
middleware: [offset(4), shift()]
30-
}).then(({ x, y }) => {
31-
Object.assign(this.contentTarget.style, {
32-
left: `${x}px`,
33-
top: `${y}px`,
34-
});
35-
});
39+
this.unmount();
40+
}
41+
42+
show() {
43+
if (!this.hasContentTarget) return;
44+
45+
this.mount();
46+
this.mounted.element.setAttribute("data-state", "open");
47+
}
48+
49+
hide() {
50+
this.mounted?.element.setAttribute("data-state", "closed");
51+
}
52+
53+
animationEnd(event) {
54+
if (event.animationName !== "exit") return;
55+
if (this.mounted?.element.getAttribute("data-state") !== "closed") return;
56+
57+
this.unmount();
58+
}
59+
60+
cloneTemplate() {
61+
return this.contentTarget.content.firstElementChild.cloneNode(true);
62+
}
63+
64+
reposition() {
65+
if (!this.mounted) return;
66+
67+
const position = { placement: this.placementValue, middleware: [offset(4), shift()] };
68+
69+
computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => {
70+
this.mounted?.element.style.setProperty("left", `${x}px`);
71+
this.mounted?.element.style.setProperty("top", `${y}px`);
3672
});
3773
}
3874
}

gem/lib/ruby_ui/tooltip/tooltip_content.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,25 @@ def initialize(**attrs)
88
end
99

1010
def view_template(&)
11-
div(**attrs, &)
11+
template(data: {ruby_ui__tooltip_target: "content"}) do
12+
div(**attrs, &)
13+
end
1214
end
1315

1416
private
1517

1618
def default_attrs
1719
{
1820
id: @id,
19-
data: {
20-
ruby_ui__tooltip_target: "content"
21-
},
22-
class: "invisible peer-hover:visible peer-focus:visible w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md peer-focus:zoom-in-95 animate-out fade-out-0 zoom-out-95 peer-hover:animate-in peer-focus:animate-in peer-hover:fade-in-0 peer-focus:fade-in-0 peer-hover:zoom-in-95 group-data-[ruby-ui--tooltip-placement-value=bottom]:slide-in-from-top-2 group-data-[ruby-ui--tooltip-placement-value=left]:slide-in-from-right-2 group-data-[ruby-ui--tooltip-placement-value=right]:slide-in-from-left-2 group-data-[ruby-ui--tooltip-placement-value=top]:slide-in-from-bottom-2 delay-500"
21+
class: [
22+
"invisible pointer-events-none w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md",
23+
"data-[placement=bottom]:slide-in-from-top-2",
24+
"data-[placement=left]:slide-in-from-right-2",
25+
"data-[placement=right]:slide-in-from-left-2",
26+
"data-[placement=top]:slide-in-from-bottom-2",
27+
"data-[state=open]:visible data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
28+
"data-[state=closed]:visible data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards"
29+
]
2330
}
2431
end
2532
end

gem/lib/ruby_ui/tooltip/tooltip_controller.js

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
33

44
export default class extends Controller {
55
static targets = ["trigger", "content"];
6-
static values = { placement: String }
76

8-
constructor(...args) {
9-
super(...args);
10-
this.cleanup;
7+
static values = { placement: "top" };
8+
9+
mount() {
10+
if (this.mounted) return;
11+
12+
const element = this.cloneTemplate();
13+
element.setAttribute("data-placement", this.placementValue);
14+
document.body.appendChild(element);
15+
16+
this.triggerTarget.setAttribute("aria-describedby", element.id);
17+
element.addEventListener("animationend", (event) => this.animationEnd(event));
18+
19+
const onBeforeCache = () => this.unmount();
20+
document.addEventListener("turbo:before-cache", onBeforeCache);
21+
22+
this.mounted = { element, onBeforeCache };
23+
this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition());
1124
}
1225

13-
connect() {
14-
this.setFloatingElement();
26+
unmount() {
27+
if (!this.mounted) return;
1528

16-
const tooltipId = this.contentTarget.getAttribute("id");
17-
this.triggerTarget.setAttribute("aria-describedby", tooltipId);
29+
document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache);
1830

31+
this.mounted.stopAutoUpdate?.();
32+
this.mounted.element.remove();
33+
this.triggerTarget.removeAttribute("aria-describedby");
34+
35+
this.mounted = null;
1936
}
2037

2138
disconnect() {
22-
this.cleanup();
23-
}
24-
25-
setFloatingElement() {
26-
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
27-
computePosition(this.triggerTarget, this.contentTarget, {
28-
placement: this.placementValue,
29-
middleware: [offset(4), shift()]
30-
}).then(({ x, y }) => {
31-
Object.assign(this.contentTarget.style, {
32-
left: `${x}px`,
33-
top: `${y}px`,
34-
});
35-
});
39+
this.unmount();
40+
}
41+
42+
show() {
43+
if (!this.hasContentTarget) return;
44+
45+
this.mount();
46+
this.mounted.element.setAttribute("data-state", "open");
47+
}
48+
49+
hide() {
50+
this.mounted?.element.setAttribute("data-state", "closed");
51+
}
52+
53+
animationEnd(event) {
54+
if (event.animationName !== "exit") return;
55+
if (this.mounted?.element.getAttribute("data-state") !== "closed") return;
56+
57+
this.unmount();
58+
}
59+
60+
cloneTemplate() {
61+
return this.contentTarget.content.firstElementChild.cloneNode(true);
62+
}
63+
64+
reposition() {
65+
if (!this.mounted) return;
66+
67+
const position = { placement: this.placementValue, middleware: [offset(4), shift()] };
68+
69+
computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => {
70+
this.mounted?.element.style.setProperty("left", `${x}px`);
71+
this.mounted?.element.style.setProperty("top", `${y}px`);
3672
});
3773
}
3874
}

gem/lib/ruby_ui/tooltip/tooltip_trigger.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@ def view_template(&)
1010

1111
def default_attrs
1212
{
13-
data: {ruby_ui__tooltip_target: "trigger"},
14-
variant: :outline,
15-
class: "peer"
13+
data: {
14+
ruby_ui__tooltip_target: "trigger",
15+
action: [
16+
"mouseenter->ruby-ui--tooltip#show",
17+
"mouseleave->ruby-ui--tooltip#hide",
18+
"focus->ruby-ui--tooltip#show",
19+
"blur->ruby-ui--tooltip#hide"
20+
]
21+
},
22+
variant: :outline
1623
}
1724
end
1825
end

0 commit comments

Comments
 (0)