Skip to content

[Feature] Tooltip: match shadcn (Base UI + vega) - #654

Merged
cirdes merged 4 commits into
mainfrom
tooltip-match-shadcn
Nov 4, 2026
Merged

cirdes merged 4 commits into
mainfrom
tooltip-match-shadcn

Conversation

@cirdes

@cirdes cirdes commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Brings Tooltip in line with shadcn (Base UI + vega), following the shadcn audit run on 2026-11-04. It also fixes the follow-up from #644: with two sidebars on one page, a collapsed sidebar's tooltips stayed hidden while the other sidebar was expanded.

What changes

  • TooltipContent is a role="tooltip" popover="manual" in the top layer, with vega's classes (bg-foreground text-background, text-xs, max-w-xs, inline-flex gap-1.5, kbd rules) and the arrow, placed by Floating UI's arrow(). The controller now adds flip() and Base UI's --transform-origin.
  • The content copy goes right after the trigger instead of into <body>, so it stays inside its container. SidebarMenuButton hides it from its own sidebar's state ([[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden) and drops the body:has(...) rule that any expanded sidebar matched.
  • TooltipTrigger is an inline-flex w-fit wrapper, so the hit area and the anchor are the button (before, the whole row opened it and the tooltip was centered on the row). It no longer emits a stray variant="outline" attribute.
  • Keyboard focus opens it (focusin, focus-visible only, as Base UI). Escape closes it, and the pointer can move onto it without closing it (WCAG 1.4.13). The focusable trigger gets aria-describedby, as the APG tooltip pattern asks; Base UI leaves that out. SidebarMenuButton skips it when the tooltip only repeats the label.
  • New options: Tooltip(open:, disabled:) plus a ruby-ui--tooltip:change event, TooltipTrigger(delay: 0, close_delay: 0, close_on_click: true) and TooltipContent(side:, side_offset: 4, align:, align_offset: 0) with logical :inline_start/:inline_end. placement: keeps working as a fallback for side:. Every part has a data-slot.
  • Adds the audit map (with baseline tooltip 8a353667) and fixtures, updates the docs and rebuilds the MCP registry.

A Codex review caught five issues, all fixed in the second commit: Escape on a tooltip hidden by CSS no longer swallows the key meant for the Sheet, hover state resets on reconnect, a press during the open delay cancels it, align_offset: works with align: :center, and the arrow points the right way in RTL.

Screenshots

Hover
Keyboard focus
Flip at the top edge
Two sidebars

Measured in headed Chromium at 1280×800 with the same script on both fixtures, RubyUI and shadcn now give the same results: opens on Tab, closes on Escape, stays open with the pointer on it, popup centered on the button (x=254 on both), flips below at the top edge (y=42).

Follow-ups

  • PopoverContent has the same align_offset: gap with align: :center (it passes only alignmentAxis). Same one-line fix, left for a Popover PR.
  • Bubble's docs and the chat theme put Text inside TooltipContent, and Text's own size class overrides the tooltip's text-xs. Their examples should use a plain p or string.
  • Base UI's Trigger defaults delay to 600 ms; RubyUI defaults to 0 because shadcn's TooltipProvider sets 0. Revisit if RubyUI gets a provider.

Test steps

  1. cd gem && bundle exec rake, cd mcp && bundle exec rake test, cd audit && bundle exec rake, cd docs && bin/rails test.
  2. /docs/tooltip: hover each example, Tab to a trigger (opens), press Escape (closes), move the pointer onto the tooltip (stays open), try the Sides row near the top of the window (flips).
  3. A page with two sidebars, one collapsible: :icon collapsed and one expanded: hovering a collapsed button shows its tooltip; the expanded sidebar's buttons show none.
Audit checklist, main
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 1998c5e8  shadcn 8a353667
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment  Behavior checks in Chrome (chrome-devtools MCP, isolated context) and headed Playwright at 1280×800; placement measured with body padding 200px on both sides.
✅ Theme  neutral tokens match shadcn
Tooltip  (ref: bases/base/ui/tooltip.tsx + style-vega.css @ 8a353667)
  ⏸ Look       TooltipContent: selector matched 0/1 visible elements in focus/light; 1/0 visible elements in after-esc/light; 0/1 visible elements in focus/dark; 1/0 visible elements in after-esc/dark — Settled in Chrome: RubyUI never opens on keyboard focus and stays open after Esc. Graded on the Behavior lines. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline. Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period). — Requested: run `baseline tooltip 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR, and commit audit/component_map/tooltip.yml + audit/fixtures/tooltip/ (created locally for this audit).
  ❌ Look       background-color: oklch(0.205 0 0); background-color: oklch(1 0 0); border-bottom-width: 1px; border-left-width: 1px; border-right-width: 1px; border-top-width: 1px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px …; color: oklch(0.145 0 0); color: oklch(0.985 0 0); column-gap: normal; display: block; gap: normal; row-gap: normal
               vega: background-color: oklch(0.145 0 0); background-color: oklch(0.985 0 0); border-bottom-width: 0px; border-left-width: 0px; border-right-width: 0px; border-top-width: 0px; box-shadow: none; color: oklch(0.145 0 0); color: oklch(1 0 0); column-gap: 6px; display: inline-flex; gap: 6px; row-gap: 6px
               TooltipContent — open/light, open/dark — Root cause: RubyUI uses the popover surface (bg-popover text-popover-foreground border shadow-md, block); vega's tooltip is inverted (bg-foreground text-background, no border, no shadow) and inline-flex items-center gap-1.5. Font size is split out below. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family, font-size, height, line-height, width
               TooltipTrigger, TooltipContent — explained by environment diff: font — Only font-family is the environment. font-size/line-height (and the width/height they drive) are the component: text-sm vs text-xs, graded on its own line. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
  ⏸ ARIA       TooltipContent: selector matched 0/1 visible elements in focus/light; 1/0 visible elements in after-esc/light; 0/1 visible elements in focus/dark; 1/0 visible elements in after-esc/dark — Settled in Chrome: RubyUI never opens on keyboard focus and stays open after Esc. Graded on the Behavior lines. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline. Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
  ✅ ARIA       TooltipTrigger: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ❌ Look       TooltipContent text-sm (14px / 20px line-height, 34px tall)
               vega: text-xs (12px / 16px, 28px tall)
               Measured in Chrome per element (collect had filed it under the font environment diff). — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
               ↳ style-vega.css:1381
  ❌ Look       TooltipContent max-w-[calc(100vw-2rem)] text-balance break-words
               vega: max-w-xs (20rem)
               Long content wraps at almost the viewport width in RubyUI, at 320px in shadcn. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
               ↳ bases/base/ui/tooltip.tsx:52
  ❌ Look       no arrow
               vega: TooltipPrimitive.Arrow: size-2.5 rotate-45 rounded-[2px] bg-foreground, -bottom-2.5 on side top (14×14 box measured)
               shadcn draws a small rotated square pointing at the trigger. — Requested: add the arrow (vega classes per side), positioned with Floating UI's arrow() middleware.
               ↳ bases/base/ui/tooltip.tsx:58 + style-vega.css:1388
  ❌ HTML       TooltipTrigger = <div> wrapper (display:block) around the Button, with a stray variant="outline" attribute and aria-describedby on the div
               shadcn: Trigger renders the Button itself (render={<Button />}) with data-slot=tooltip-trigger
               variant: :outline in TooltipTrigger#default_attrs is emitted as an HTML attribute. The block wrapper is the root cause of the hit-area and placement lines below. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
               ↳ bases/base/ui/tooltip.tsx:24
  ❌ HTML       no data-slot on Tooltip, TooltipTrigger, TooltipContent
               shadcn: data-slot=tooltip / tooltip-trigger / tooltip-content
               Tooltip root is a div.group carrying the Stimulus controller (needed in RubyUI; Base UI Root renders nothing). <template> + clone into <body> stands in for the Portal. — Requested: data-slot tooltip / tooltip-trigger / tooltip-content (content keeps it in the rendered node).
               ↳ bases/base/ui/tooltip.tsx:20
  ❌ Props      Tooltip: placement: only
               shadcn: Root: open, defaultOpen, onOpenChange, disabled (Base UI Tooltip.Root)
               disabled is what the Sidebar needs (shadcn passes hidden per sidebar). Provider (delay, default 0 in shadcn) has no RubyUI part; RubyUI opens immediately, which matches. — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
               ↳ @base-ui/react 1.9.0 tooltip/root/TooltipRoot.d.ts:21-56
  ❌ Props      TooltipTrigger: no kwargs
               shadcn: delay (600), closeDelay (0), closeOnClick (true)
               closeOnClick: Base UI closes the tooltip when the trigger is pressed; RubyUI keeps it open (the fixture turns it off on the shadcn side so collect can reach open). — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
               ↳ @base-ui/react 1.9.0 tooltip/trigger/TooltipTrigger.d.ts:31-50
  ❌ Props      TooltipContent: no kwargs (offset fixed at 4, align always center); placement: lives on Tooltip
               shadcn: side (top), sideOffset (4), align (center), alignOffset (0)
               side → Tooltip(placement:) works for top/right/bottom/left; inline-start/inline-end (logical sides) are not supported. — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
               ↳ bases/base/ui/tooltip.tsx:29-32
  ⚠️ Behavior   Keyboard focus never opens the tooltip
               shadcn: Opens on focus (data-instant=focus)
               focus->/blur-> actions sit on the wrapper <div>; focus doesn't bubble, so focusing the Button inside never fires show(). Keyboard users never see it. Checked with Tab in Chrome. (SidebarMenuButton puts the actions on the button itself, so the sidebar tooltip does open on focus.) — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
               ↳ bases/base/ui/tooltip.tsx:24
  ⚠️ Behavior   Esc leaves it open
               shadcn: Esc closes it
               Checked in Chrome and headed Playwright. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#keyboard_interaction
  ⚠️ Behavior   Not hoverable: pointer-events-none and mouseleave on the trigger hide it when the pointer moves onto it
               shadcn: Pointer can move onto the popup and it stays open
               Headed Playwright: pointer moved from trigger into the popup → RubyUI closed, shadcn stayed open. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
               ↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
  ⚠️ Behavior   Hit area and anchor = full-width wrapper: hovering at x=900px on the trigger's row opens it; tooltip centered at x=640 while the button's center is x=254 (386px off)
               shadcn: Hit area and anchor = the button; popup centered on it (254 vs 254), 4px gap
               Fixture in a block container at 1280×800, body padding 200px. In a flex row the wrapper shrinks to the button, so docs examples may hide it. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
               ↳ bases/base/ui/tooltip.tsx:24
  ⚠️ Behavior   No flip: trigger at the top edge puts the tooltip at y=-38 (off-screen)
               shadcn: Base UI flips to bottom (data-side=bottom) when there's no room on top
               Controller middleware is offset(4) + shift() only; add flip(). — Requested: add flip() and write the final side to data-side so slide/arrow follow it.
               ↳ bases/base/ui/tooltip.tsx:45
  ⚠️ Behavior   Two sidebars on one page: hovering a collapsed sidebar's menu button gives its tooltip display:none when another sidebar is expanded
               shadcn: Each tooltip is hidden only by its own sidebar's state (hidden={state !== "collapsed" || isMobile})
               Follow-up from #644, reproduced with a temporary fixture (one collapsed left sidebar, one expanded right sidebar): with both, the tooltip is open but display:none; removing the expanded sidebar shows it. Cause: the clone lives in <body>, so SidebarMenuButton hides it with body:has([data-slot=sidebar][data-state=expanded]), which any expanded sidebar matches. — Requested: move TooltipContent to popover="manual" (top layer, node stays in place); SidebarMenuButton hides its tooltip from its own sidebar's state via CSS and drops body:has(...).
               ↳ bases/base/ui/sidebar.tsx:555
  ✅ Behavior   Opens instantly on hover (no delay)
               Same as shadcn with its Provider.
               ↳ bases/base/ui/tooltip.tsx:7
  ⚠️ ARIA       aria-describedby set on the wrapper <div>, not on the focusable Button; popup has no role
               shadcn: No aria-describedby, no role=tooltip on the popup (generic)
               Neither side announces the tooltip text as the button's description. RubyUI's attribute lands on an element AT never focuses, so it has no effect. — Requested: follow APG — role="tooltip" on the content and aria-describedby on the focusable trigger (diverges from shadcn, follows the spec).
               ↳ https://www.w3.org/TR/wai-aria-1.2/#aria-describedby
  ⚠️ Spec       Popup without role=tooltip; describedby on a non-focusable ancestor
               shadcn: Popup without role=tooltip; trigger without aria-describedby
               APG recommendation (not normative): tooltip container has role tooltip and the trigger references it with aria-describedby. Base UI leaves this out on purpose (it asks for an aria-label on icon triggers). RubyUI copies the miss. — Requested: follow APG — role="tooltip" on the content and aria-describedby on the focusable trigger (diverges from shadcn, follows the spec).
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
  ⚠️ Spec       Not dismissable with Esc, not hoverable, not shown on focus
               shadcn: Dismissable, hoverable, shown on focus
               WCAG 2.2 SC 1.4.13 (AA): content shown on hover or focus must be dismissible without moving pointer/focus and hoverable. RubyUI alone misses it; WCAG is outside the weights table, so ⚠️ rather than ❌. APG also lists Escape: dismisses the tooltip. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
               ↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
  ✅ Animation  Enter: animation 'enter' 0.15s, fade-in-0 zoom-in-95 + slide-in by placement
               Measured with getComputedStyle on both sides at open.
               ↳ style-vega.css:1381
  ⚠️ Animation  Zoom from the popup center (no transform-origin)
               shadcn: origin-(--transform-origin): zooms out of the trigger side
               Visible as a subtle scale from the middle instead of from the arrow. — Requested: controller sets --transform-origin from the final side/arrow; content uses origin-(--transform-origin).
               ↳ bases/base/ui/tooltip.tsx:52
  ❌ Native     JS clones a <template> into <body> and toggles data-state; z-50 for stacking
               shadcn: Base UI Portal + Positioner
               Candidate for popover="manual" (Baseline): top layer without moving the node, so the content stays inside the sidebar and its own data-state can hide it (fixes the two-sidebar line). Floating UI still positions it; Esc and hover/focus wiring stay in the controller. pt-BR MDN page is 404. — Requested: move TooltipContent to popover="manual" (top layer, node stays in place); SidebarMenuButton hides its tooltip from its own sidebar's state via CSS and drops body:has(...).
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover

Audit checklist, this PR
shadcn audit  ruby-ui/ruby_ui
  base 1998c5e8  head 440ce9ab  shadcn 8a353667
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment  font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment  Behavior checks in headed Playwright at 1280×800; placement measured with body padding 200px on both sides.
✅ Theme  neutral tokens match shadcn
Tooltip  (ref: bases/base/ui/tooltip.tsx + style-vega.css @ 8a353667)
  ✅ Look       display: flex
               TooltipTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — Override: renders the same. RubyUI's wrapper is inline-flex, so its Button child is blockified (inline-flex → flex); the content is position: fixed in the top layer, which blockifies it too. shadcn's Button and popup sit in block parents. Same box, size and position measured in Chrome (popup centered at x=254 on both).
               ↳ computed styles (shadcn-audit collect)
  ✅ Look       display: flex
               TooltipContent — open/light, focus/light, open/dark, focus/dark — Override: renders the same. RubyUI's wrapper is inline-flex, so its Button child is blockified (inline-flex → flex); the content is position: fixed in the top layer, which blockifies it too. shadcn's Button and popup sit in block parents. Same box, size and position measured in Chrome (popup centered at x=254 on both).
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       properties: font-family
               TooltipTrigger, TooltipContent — explained by environment diff: font
  ✅ ARIA       aria-describedby: Add to library — follows spec
               TooltipTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — Diverges from shadcn, follows the spec: APG tooltip pattern asks for role=tooltip on the popup and aria-describedby on the trigger; Base UI leaves both out.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
  ✅ ARIA       snapshot: only here: - tooltip "Add to library": |   - paragraph: Add to library — follows spec
               TooltipContent — open/light, focus/light, open/dark, focus/dark — Diverges from shadcn, follows the spec: APG tooltip pattern asks for role=tooltip on the popup and aria-describedby on the trigger; Base UI leaves both out.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
  ✅ Look       TooltipContent text-xs (12px / 16px), max-w-xs, bg-foreground text-background, no border/shadow, arrow size-2.5 rotate-45
               Computed styles match in collect; arrow screenshot matches pixel for pixel at keyboard focus.
               ↳ style-vega.css:1380-1393
  ✅ HTML       TooltipTrigger: inline-flex w-fit wrapper with data-slot=tooltip-trigger, no stray variant attribute; data-slot tooltip / tooltip-content / tooltip-arrow
               The wrapper stays (Phlex has no render prop), but it shrinks to the Button, so hit area and anchor are the Button.
               ↳ bases/base/ui/tooltip.tsx:20-58
  ✅ Props      Tooltip: open:, disabled:, change event; TooltipTrigger: delay: (0), close_delay: (0), close_on_click: (true); TooltipContent: side:, side_offset: (4), align:, align_offset: (0)
               open: is the initial state (defaultOpen); the open value attribute and ruby-ui--tooltip:change cover controlled use. delay defaults to 0 as shadcn's TooltipProvider. placement: kept as an alias (rubyui_only).
               ↳ bases/base/ui/tooltip.tsx:29-32 + @base-ui/react 1.9.0 tooltip/*.d.ts
  ✅ Behavior   Opens on keyboard focus, Esc closes, hoverable, hit area = button (pointer at x=900 doesn't open), centered on the button (254 vs 254), flips to bottom at the top edge (y=42, data-side=bottom)
               Headed Playwright, same script on both sides (verify-after.mjs).
               ↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
  ✅ Behavior   Two sidebars on one page: the collapsed sidebar's tooltip shows (display:flex) while the other sidebar is expanded; the expanded sidebar's own tooltip stays hidden
               Fixes the #644 follow-up. The content sits after the button inside its sidebar, hidden by [[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden.
               ↳ bases/base/ui/sidebar.tsx:555
  ✅ Behavior   Opens instantly on hover (delay 0)
               ↳ bases/base/ui/tooltip.tsx:7
  ✅ Spec       role=tooltip on the popup, aria-describedby on the focusable trigger; Esc, hover and focus per WCAG 1.4.13 — follows spec
               Diverges from shadcn, follows the spec (APG recommendation). SidebarMenuButton skips aria-describedby when the tooltip repeats its label.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
  ✅ Animation  Enter 'enter' 0.15s fade/zoom/slide by data-side; zoom from --transform-origin (trigger side)
               ↳ style-vega.css:1381 + bases/base/ui/tooltip.tsx:52
  ✅ Native     popover="manual": top layer, node stays next to the trigger; Floating UI (offset, flip, shift, arrow) places it — native
               Esc and hover/focus wiring stay in the controller (popover="auto" would light-dismiss on any click and skip the exit animation). Nothing shadcn delivers is lost.
               ↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover

🤖 Generated with Claude Code


Summary by cubic

Brings Tooltip in line with shadcn (Base UI + vega), adding keyboard focus, Escape dismissal, and hover support per WCAG 1.4.13, plus new placement and interaction options. Also fixes a follow-up from #644 where a collapsed sidebar's tooltips stayed hidden when another sidebar was expanded.

Key changes

  • TooltipContent is now a role="tooltip" popover="manual" in the top layer with vega styling and an arrow; it sits right after the trigger, so it stays inside its container.
  • Adds open:, disabled:, delay:, close_delay:, close_on_click:, side:, side_offset:, align:, and align_offset: options (placement: stays as a fallback).
  • The tooltip now opens on keyboard focus, closes on Escape, and stays open while the pointer is over it; the focusable trigger gets aria-describedby.
  • open: and disabled: are coerced to booleans so nil doesn't render an empty Stimulus value, and an open set while disabled takes effect once re-enabled.
  • SidebarMenuButton hides its tooltip from only its own sidebar's state, fixing tooltips staying hidden when another sidebar is expanded.
  • Adds the audit map, fixtures, docs updates, and rebuilds the MCP registry.

Written for commit 7000847. Summary will update on new commits.

View guided diff Turn on auto-fix

cirdes and others added 3 commits November 4, 2026 11:56
TooltipContent is now a role="tooltip" popover="manual" with vega's
classes (bg-foreground text-background, text-xs, max-w-xs, inline-flex
gap-1.5) and an arrow placed by Floating UI's arrow(). The controller
places the content right after the trigger instead of in <body>, so it
stays inside its container, and adds flip() and Base UI's
--transform-origin.

TooltipTrigger becomes an inline-flex w-fit wrapper, so the hit area and
the anchor are the button, and drops the stray variant="outline"
attribute. The tooltip now opens on keyboard focus (focusin), closes on
Escape, and stays open while the pointer is over it (WCAG 1.4.13). The
focusable trigger gets aria-describedby, as the APG tooltip pattern asks.

New options: Tooltip(open:, disabled:) with a ruby-ui--tooltip:change
event, TooltipTrigger(delay:, close_delay:, close_on_click:) and
TooltipContent(side:, side_offset:, align:, align_offset:). placement:
still works as a fallback for side:. data-slot on every part.

SidebarMenuButton hides its tooltip from its own sidebar's state, which
fixes tooltips staying hidden when another sidebar on the page is
expanded (follow-up from #644).

Adds the audit map (with the drift baseline) and fixtures, updates the
docs and rebuilds the MCP registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Escape on a tooltip hidden by CSS (expanded or mobile sidebar) closes it
  without taking the key, so the Sheet or dialog around it still closes
- Reset hover and press state on disconnect, since Stimulus reuses the
  controller when the element comes back
- A press during the open delay cancels the pending open
- align_offset: also shifts a centered tooltip (Floating UI crossAxis)
- The arrow takes the resolved physical side, so logical sides point the
  right way in RTL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 15 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread gem/lib/ruby_ui/tooltip/tooltip.rb Resolved
Comment thread gem/test/ruby_ui/sidebar_test.rb Resolved
Comment thread docs/app/views/docs/tooltip.rb Resolved
Comment thread gem/lib/ruby_ui/tooltip/tooltip_docs.rb Resolved
- Coerce open:/disabled: to real booleans, so nil doesn't render an empty
  value that Stimulus reads as true
- An open value set while disabled takes effect once re-enabled
- No change events for the internal remount
- placement: "inline-start"/"inline-end" stays a logical side
- Docs: blur closes unless the pointer is still over the tooltip; the
  change event fires on open and close

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cirdes
cirdes merged commit 5c5c721 into main Nov 4, 2026
10 checks passed
@cirdes
cirdes deleted the tooltip-match-shadcn branch November 4, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant