[Improvement] Add exit animation, remove delay, lazy-mount Tooltip content#393
Conversation
…ntent 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.
Improves readability by grouping related Tailwind variants (placement, state) and separating each Stimulus action descriptor on its own line.
There was a problem hiding this comment.
1 issue found across 4 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/tooltip/tooltip_controller.js">
<violation number="1" location="gem/lib/ruby_ui/tooltip/tooltip_controller.js:7">
P1: Invalid Stimulus values definition syntax. Passing a raw string `"top"` as the value definition is not supported by Stimulus — it expects either a Type constructor or a `{ type, default }` object. This will cause `this.placementValue` to not work correctly, breaking tooltip positioning.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.
| constructor(...args) { | ||
| super(...args); | ||
| this.cleanup; | ||
| static values = { placement: "top" }; |
There was a problem hiding this comment.
P1: Invalid Stimulus values definition syntax. Passing a raw string "top" as the value definition is not supported by Stimulus — it expects either a Type constructor or a { type, default } object. This will cause this.placementValue to not work correctly, breaking tooltip positioning.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/tooltip/tooltip_controller.js, line 7:
<comment>Invalid Stimulus values definition syntax. Passing a raw string `"top"` as the value definition is not supported by Stimulus — it expects either a Type constructor or a `{ type, default }` object. This will cause `this.placementValue` to not work correctly, breaking tooltip positioning.</comment>
<file context>
@@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
- constructor(...args) {
- super(...args);
- this.cleanup;
+ static values = { placement: "top" };
+
+ mount() {
</file context>
| static values = { placement: "top" }; | |
| static values = { placement: { type: String, default: "top" } }; |
There was a problem hiding this comment.
LGTM 👏 Lazy-mount via <template> + portal to body is the right approach — avoids CSS matching / layout for unused tooltips and dodges clipping by overflow: hidden ancestors in the trigger subtree.
Controller lifecycle is clean: mount/unmount symmetric, disconnect calls unmount, turbo:before-cache listener torn down alongside it, and the ?. on the async autoUpdate callback protects against unmount racing the pending computePosition Promise. animationName === "exit" filter matches the tw-animate-css keyframe (@keyframes exit), confirmed in the bundle.
fill-mode-forwards + pointer-events-none correctly handle the "invisible element still in layout post animate-out" case.
Related issue
N/A — alignment with shadcn/ui defaults and behavior, following the Preserving the shadcn look and feel focus area in
CONTRIBUTING.md.Description
Three issues with the current Tooltip, all fixed against shadcn's reference implementation:
1. No close animation
The current
peer-hover:visible+peer-hover:animate-inpattern toggles visibility binarily — when the trigger is no longer hovered, the content's classes drop and the element snaps away with no exit animation playing.Switched to the
data-state="open"|"closed"pattern that shadcn/Radix use, with paired Tailwind variants:fill-mode-forwardson the closed state is required becausetw-animate-cssdefaults toanimation-fill-mode: none. Without it, theanimate-outkeyframes playopacity 1 → 0and the element then snaps back toopacity: 1. With forwards, it stays at the final keyframe and is invisible until the next open.2. Hardcoded 500ms delay
The current code applies
delay-500unconditionally, which delays both open and close. shadcn overrides Radix's 700ms default withdelayDuration: 0— the tooltip opens instantly when the cursor enters the trigger.Removed
delay-500entirely. Hover/focus now opens the tooltip immediately.3. Eager rendering of every tooltip's HTML
Even when a page has 30 tooltips and the user never hovers any of them, each
TooltipContentdiv is rendered into the active DOM and gets full CSS matching, layout, and Stimulus target scanning.Now the content is wrapped in a
<template>element.<template>children live in aDocumentFragmentoutside the active document tree — the browser does not apply CSS to them, does not compute layout, and Stimulus does not scan them for targets. On the first hover/focus, the controller clones the template's child intodocument.body, positions it with Floating UI, and togglesdata-state. On the close animation'sanimationend, it unmounts (removes from body, stopsautoUpdate).Other supporting changes
mouseenter/mouseleave/focus/bluron a singledata-action) instead of CSS sibling selectors. This also lets the cloned content live outside the trigger's subtree (inbody), avoiding clipping byoverflow: hiddenancestors.class: \"peer\"removed from the trigger — the sibling-selector pattern is gone.turbo:before-cachelistener unmounts the tooltip before Turbo snapshots the page, keeping cached snapshots clean (a tooltip inbodywould otherwise be orphaned from its controller on cache restore).pointer-events-noneon the content base — the cloned element stays in the layout atopacity: 0after close (because offill-mode-forwards), so it shouldn't capture the cursor.Testing instructions
fade-in+zoom-in+slide-in(no 500ms wait).fade-out+zoom-outand disappears.tooltip-content-*element exists inbody.tooltip-content-*is removed frombodyonce the exit animation ends.cd gem && bundle exec rake→ tests + standardrb pass (existing assertions onw-fit,max-w-[calc(100vw-2rem)],break-wordsare preserved).