[Feature] Sheet: match shadcn (Base UI + vega)

Merged
CI
2d ago
+639-205

Matches Sheet to shadcn (Base UI base + vega, neutral theme), following the decisions from the shadcn audit of the component on main.

Sheet is now a native <dialog> opened with showModal(), the same approach as Dialog and AlertDialog. The browser makes the page behind inert and handles Esc and initial focus. The controller adds what Base UI also does: a Tab loop inside the panel, focus back on the trigger, body scroll lock, the exit animation, outside click to close, and the ARIA wiring (aria-labelledby/aria-describedby from SheetTitle/SheetDescription; aria-haspopup, aria-expanded and aria-controls on the trigger). The controller follows AlertDialog's lifecycle: it closes and unlocks synchronously on turbo:before-cache, never restores a cached <dialog open> half-open, rebinds when Turbo replaces only the panel, and releases the scroll lock only if this sheet took it and no other modal is open. Before this PR the panel was a <div> copied from a <template> into <body> on every open. It had no dialog role or accessible name, focus stayed on the trigger behind it, Tab left the panel and Esc did nothing.

Look follows vega: bg-popover, flex flex-col gap-4 text-sm, w-3/4 sm:max-w-sm on the left and right sides, p-4 on the header and footer (the footer sits at the bottom with mt-auto), an <h2> title with font-heading font-medium, a bg-black/10 backdrop-blur-xs backdrop, and a 32px ghost close button. The panel fades in and slides 2.5rem in 200ms, and the backdrop fades in 150ms. Every part has a data-slot, and SheetContent has data-side.

The side classes are plain utilities instead of data-[side=…] variants, so a caller's width or padding still replaces them through tailwind_merge (MobileSidebar passes w-[var(--sidebar-width)] p-0).

Changes for people already using Sheet:

  • SheetContent(show_close_button: false) hides the close button (shadcn's showCloseButton).
  • New markup closes with click->ruby-ui--sheet#dismiss. click->ruby-ui--sheet-content#close keeps working through a small shim controller, so existing apps don't break.
  • SheetMiddle is now px-4 (it was py-4), because the panel no longer has p-6 of its own. It mirrors the <div className="px-4"> body in shadcn's examples.
  • A caller class like flex no longer shows a closed sheet: the panel has not-open:hidden.

MobileSidebar is a Sheet, so on mobile the sidebar is now a modal dialog named "Sidebar", focus moves to its first link, and Esc closes it and returns focus to the toggle. It now hides the close button with show_close_button: false instead of [&>button]:hidden. The docs site's mobile menu gets an sr-only SheetTitle so its dialog has a name.

Audit: audit/component_map/sheet.yml has new selectors, the open and showCloseButton props, and the baseline shadcn_sha 8a353667. The fixture uses the new dismiss action. audit/divergences/sheet.yml registers the native <dialog>, the ::backdrop overlay, the root wrapper (display: contents, since shadcn's Root renders no element), the trigger wrapper <div> and the native modal ARIA, all as Dialog and AlertDialog already do. The MCP registry is rebuilt.

Before / after

Open (light)

Open (dark)

After Esc (light): on main the sheet stays open

Mobile sidebar (this PR, 390px):

Mobile sidebar open

The one ⏸ left is the SheetTitle letter-spacing (-0.35px vs normal). It comes from the docs app's global h1…h6 { @apply tracking-tight }, not from the component.

shadcn audit checklist, main (before)
shadcn audit  ruby-ui/ruby_ui
  base 25ccda6b  head 25ccda6b  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  ports 5491 (shadcn) / 3491 (RubyUI), parallel audits on the macmini; behavior checks in real Chrome (chrome-devtools MCP, isolated context) and headed Playwright
✅ Theme  neutral tokens match shadcn
Sheet  (ref: bases/base/ui/sheet.tsx + style-vega.css @ 8a353667)
  ❌ Look       background-color: oklab(1 0 0 / 0.045); background-color: oklch(1 0 0)
               vega: background-color: oklab(1 0 0 / 0.075); background-color: oklch(0.97 0 0)
               SheetTrigger — open/light, after-esc/light, after-esc/dark. Root cause: shadcn trigger gets aria-expanded:bg-muted while open; RubyUI never sets aria-expanded (see ARIA SheetTrigger). Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does. Requested: run `baseline sheet 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       SheetContent: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ❌ Look       background-color: oklch(0.145 0 0); display: block; padding-bottom: 24px; padding-left: 24px; padding-right: 24px; padding-top: 24px
               vega: background-color: oklch(0.205 0 0); display: flex; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px
               SheetContent — open/light, open/dark. RubyUI: bg-background p-6 (block) vs shadcn bg-popover flex flex-col gap-4 (padding on header/footer p-4). Requested: adopt vega classes on SheetContent/Header/Footer incl. per-side width/height and text-sm; drop overflow-scroll and text-center sm:text-left.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       SheetHeader: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ❌ Look       column-gap: normal; gap: normal; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; row-gap: normal; text-align: left
               vega: column-gap: 6px; gap: 6px; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; padding-top: 16px; row-gap: 6px; text-align: start
               SheetHeader — open/light, open/dark. RubyUI: flex-col space-y-1.5 text-center sm:text-left (no padding) vs gap-1.5 p-4. Requested: adopt vega classes on SheetContent/Header/Footer incl. per-side width/height and text-sm; drop overflow-scroll and text-center sm:text-left.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       SheetFooter: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ❌ Look       column-gap: normal; gap: 0px normal; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; row-gap: 0px
               vega: column-gap: 8px; gap: 8px; padding-bottom: 16px; padding-left: 16px; padding-right: 16px; padding-top: 16px; row-gap: 8px
               SheetFooter — open/light, open/dark. RubyUI: flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 (no padding, no mt-auto) vs mt-auto flex-col gap-2 p-4. Requested: adopt vega classes on SheetContent/Header/Footer incl. per-side width/height and text-sm; drop overflow-scroll and text-center sm:text-left.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       SheetTitle: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ✅ Look       text-align: left
               SheetTitle — open/light, open/dark. Override: text-align left vs start render the same in LTR (getComputedStyle checked). Class tokens text-lg font-semibold leading-none tracking-tight vs font-medium font-heading fall under the font env diff line.
               ↳ computed styles (shadcn-audit collect)
  ⏸ Look       SheetDescription: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ✅ Look       text-align: left
               SheetDescription — open/light, open/dark. Override: text-align left vs start render the same in LTR (getComputedStyle checked).
               ↳ computed styles (shadcn-audit collect)
  ❌ Look       SheetContent font-size 16px / line-height 24px / width 459.6px; SheetTitle 18px, weight 600, letter-spacing -0.45px, line-height 18px; SheetHeader height 44px; SheetFooter height 36px
               vega: SheetContent 14px / 20px / 384px (text-sm, w-3/4 sm:max-w-sm); SheetTitle 14px, weight 500, normal, 20px (font-medium font-heading); SheetHeader 78px (p-4); SheetFooter 112px (mt-auto p-4)
               Override of the font env diff: per-element diff of collect.json (open/light) shows only font-family is environmental (both use Inter Variable, fallback list differs). The other values come from classes: missing text-sm on the content, title text-lg font-semibold tracking-tight leading-none, header/footer padding, panel width. Requested: adopt vega classes on SheetContent/Header/Footer incl. per-side width/height and text-sm; drop overflow-scroll and text-center sm:text-left.
               ↳ style-vega.css:1085,1093,1097,1101
  ⚠️ ARIA       aria-controls: (none); aria-expanded: (none); aria-haspopup: (none); snapshot: only here: - button "Open sheet"
               shadcn: aria-controls: resolves; aria-expanded: false; aria-expanded: true; aria-haspopup: dialog; snapshot: (none)
               SheetTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       SheetContent: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ⚠️ ARIA       aria-describedby: (none); snapshot: only here: - heading "Edit profile" [level=3] | - paragraph: Make changes to your profile here. | - paragraph: Your name and email are shown on your public profile. | - button "Cancel" | - button "Sav…
               shadcn: aria-describedby: Make changes to your profile here.; snapshot: only here: - dialog "Edit profile": |   - heading "Edit profile" [level=2] |   - paragraph: Make changes to your profile here. |   - paragraph: Your name and email are shown on your public profile. | …
               SheetContent — open/light, open/dark Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       SheetHeader: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ⚠️ ARIA       snapshot: only here: - heading "Edit profile" [level=3]
               shadcn: snapshot: only here: - heading "Edit profile" [level=2]
               SheetHeader — open/light, open/dark Requested: SheetTitle <h2> with text-foreground font-medium font-heading.
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       SheetFooter: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ⏸ ARIA       SheetTitle: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ⚠️ ARIA       snapshot: only here: - heading "Edit profile" [level=3]
               shadcn: snapshot: only here: - heading "Edit profile" [level=2]
               SheetTitle — open/light, open/dark Requested: SheetTitle <h2> with text-foreground font-medium font-heading.
               ↳ accessibility tree (shadcn-audit collect)
  ⏸ ARIA       SheetDescription: selector matched 1/0 visible elements in after-esc/light; 1/0 visible elements in after-esc/dark. RubyUI stays open after Esc (no Escape handler); see the Behavior Esc line. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
  ❌ Look       overlay bg-background/80 backdrop-blur-sm (computed oklab(1 0 0 / 0.8), blur 8px)
               vega: overlay bg-black/10 supports-backdrop-filter:backdrop-blur-xs (oklab(0 0 0 / 0.1), blur 4px)
               RubyUI washes the page in white 80%; shadcn dims it 10% black. Requested: overlay bg-black/10 supports-backdrop-filter:backdrop-blur-xs.
               ↳ style-vega.css:1081
  ❌ Look       panel width from content (459.6px in the fixture), no w-3/4 / sm:max-w-sm; overflow-scroll shows scrollbars
               vega: data-[side=right]:w-3/4 sm:max-w-sm → 384px at 1280px
               Docs pass class: sm:max-w-sm by hand; the component itself has no width. Requested: adopt vega classes on SheetContent/Header/Footer incl. per-side width/height and text-sm; drop overflow-scroll and text-center sm:text-left.
               ↳ style-vega.css:1085
  ❌ Look       close: 16×16 bare <button> rounded-sm opacity-70 focus:ring-2 ring-offset-2, Radix-style 15px svg
               vega: close: Button variant=ghost size=icon-sm (32×32, rounded-lg 8px, opacity 1) + lucide X, absolute top-4 right-4
               Measured in the browser: 16×16 vs 32×32, radius 6px vs 8px. Requested: close via Button(variant: :ghost, size: :icon_sm) + lucide X; add show_close_button: true to SheetContent and map showCloseButton.
               ↳ bases/base/ui/sheet.tsx:63-71
  ❌ HTML       SheetTitle renders <h3>
               shadcn: SheetTitle renders <h2> (Base UI Dialog.Title)
               Also seen as ARIA heading level 3 vs 2. Requested: SheetTitle <h2> with text-foreground font-medium font-heading.
               ↳ bases/base/ui/sheet.tsx:108-115
  ❌ HTML       no data-slot / data-side on any part; content copied from <template> into <body> with insertAdjacentHTML on every open
               shadcn: data-slot per part, data-side on sheet-content, popup portaled once
               Requested: render the <dialog> in place (no template clone); data-slot on every part, data-side on SheetContent.
               ↳ bases/base/ui/sheet.tsx:52-60
  ✅ Props      side: (:top/:right/:bottom/:left, default :right)
               RubyUI-only: Sheet(open:) opens on connect.
               ↳ bases/base/ui/sheet.tsx:42-46
  ❌ Props      no show_close_button (close button always rendered)
               shadcn: showCloseButton (default true)
               Map has showCloseButton: null. Requested: close via Button(variant: :ghost, size: :icon_sm) + lucide X; add show_close_button: true to SheetContent and map showCloseButton.
               ↳ bases/base/ui/sheet.tsx:43-47
  ⚠️ ARIA       page behind not hidden: no aria-hidden/inert on siblings
               shadcn: #root gets aria-hidden="true" while open
               From the background probe; CDP tree (real Chrome) still lists "Open sheet" behind the RubyUI sheet. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:50-52
  ⚠️ Behavior   initial focus stays on the trigger behind the sheet
               shadcn: focus moves to the first tabbable inside (Cancel)
               Real Chrome (chrome-devtools MCP) and Playwright agree. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:52
  ⚠️ Behavior   no focus trap: Tab/Shift+Tab reach the trigger behind and leave the document (hasFocus false)
               shadcn: Tab loops Cancel → Save changes → Close → Cancel; Shift+Tab reverses
               Confirmed in real Chrome. Playwright showed a Base UI focus-guard leak on shadcn: headless/automation artifact (memory playwright-modal-artifacts). Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:52
  ⚠️ Behavior   Esc does nothing: sheet stays open
               shadcn: Esc closes and focus returns to the trigger
               Real Chrome and Playwright. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Behavior   outside click on the backdrop closes
               Real mouse click at (796,400) in Playwright, both sides closed.
               ↳ bases/base/ui/sheet.tsx:50-52
  ⚠️ Behavior   after Cancel / outside click focus lands on <body>
               shadcn: focus returns to the trigger
               Real click on Cancel. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:63
  ⚠️ Behavior   no scroll lock: body overflow visible, wheel scrolls the page (scrollY 200 → 1000)
               shadcn: body overflow hidden, wheel blocked (scrollY 200 → 600, only the programmatic scrollBy moved it)
               Same probe both sides: min-height 300vh, scrollTo 200, open, scrollBy 400 + wheel 400. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ bases/base/ui/sheet.tsx:52
  ⚠️ Animation  tw-animate slide-in-from-right full width, 500ms enter / 300ms exit on the panel; backdrop fade 150ms
               shadcn: transition opacity + translate 2.5rem, 200ms ease-in-out both ways; overlay opacity 150ms
               Sampled with document.getAnimations() right after open and after Cancel. Exit runs on both sides. Requested: vega transition, opacity + 2.5rem translate per side, 200ms ease-in-out; overlay 150ms opacity; keep the exit settle.
               ↳ bases/base/ui/sheet.tsx:56
  ❌ Native     hand-rolled <div> modal: template clone, Stimulus close, backdrop div
               shadcn: candidate for <dialog> + showModal() (top layer, inert page, Esc, initial focus)
               Native would bring Esc, inertness and focus in for free; scroll lock and exit animation still need CSS/JS (native-html.md). Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ❌ Spec       no role=dialog and no accessible name on the panel
               shadcn: role="dialog", aria-labelledby → "Edit profile"
               WAI-ARIA 1.2 dialog: Accessible Name Required = True; RubyUI exposes no dialog at all. shadcn complies. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#dialog
  ⚠️ Spec       focus does not move in on open, no Esc, page behind not hidden
               shadcn: focus in, Esc closes, focus back to trigger, #root aria-hidden
               APG recommendation, unmet only on RubyUI. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction
  ⚠️ Spec       no aria-modal (and no modal behavior)
               shadcn: no aria-modal; compensates with aria-hidden on #root
               APG: dialog has aria-modal="true". shadcn misses it but hides the page; RubyUI misses both. Requested: rewrite Sheet on <dialog> + showModal() like Dialog (ARIA on trigger/title/description, body overflow-hidden, focus return); register the HTML divergence as dialog.yml does.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#wai-ariaroles,states,andproperties

shadcn audit checklist, this PR (after)
shadcn audit  ruby-ui/ruby_ui
  base de613350  head 35df4b0a  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  ports 5491 (shadcn) / 3491 (RubyUI), parallel audits on the macmini; behavior checks in real Chrome (chrome-devtools MCP, isolated context) and headed Playwright
✅ Theme  neutral tokens match shadcn
Sheet  (ref: bases/base/ui/sheet.tsx + style-vega.css @ 8a353667)
  ⏸ Look       properties: font-family, letter-spacing
               SheetTrigger, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription — explained by environment diff: font. Checked per element (open/light): only SheetTitle letter-spacing differs (-0.35px vs normal), from the docs app global rule `h1…h6 { @apply tracking-tight }` (docs/app/assets/stylesheets/application.tailwind.css:177), not from the component; the rest is font-family fallback.
  ✅ ARIA       snapshot: only here: - button "Open sheet" [expanded]
               SheetTrigger — open/light, open/dark. Override: Playwright ariaSnapshot ignores modal inertness (known artifact). In real Chrome the CDP tree hides the trigger behind the open sheet on both sides; closed, both show button "Open sheet" expandable haspopup="dialog".
               ↳ chrome-devtools MCP take_snapshot (real Chrome)
  ✅ ARIA       modal: true — intentional
               SheetContent — open/light, open/dark. Registered divergence: showModal() makes the sheet modal natively and inerts the page behind, as Dialog/AlertDialog.
               ↳ audit/divergences/sheet.yml
  ✅ ARIA       SheetHeader, SheetFooter, SheetTitle, SheetDescription: accessibility tree matches
               ↳ accessibility tree (shadcn-audit collect)
  ✅ Look       overlay ::backdrop bg-black/10 backdrop-blur-xs (oklab(0 0 0 / 0.1), blur 4px)
               Computed on ::backdrop.
               ↳ style-vega.css:1081
  ✅ Look       panel 384×800 at 1280px (w-3/4 sm:max-w-sm), no overflow-scroll
               Side classes are plain utilities so a caller width wins via tailwind_merge.
               ↳ style-vega.css:1085
  ✅ Look       close 32×32, radius 8px, opacity 1, ghost icon-sm classes, lucide X, top-4 end-4
               end-4 = right-4 in LTR.
               ↳ bases/base/ui/sheet.tsx:63-71
  ✅ HTML       SheetTitle <h2>
               ↳ bases/base/ui/sheet.tsx:108-115
  ✅ HTML       data-slot on every part, data-side on sheet-content; <dialog> rendered in place (no template clone)
               ↳ bases/base/ui/sheet.tsx:52-60
  ✅ HTML       <dialog> + showModal(); overlay is ::backdrop; trigger <div> wrapper — intentional
               Registered divergences (3 HTML entries).
               ↳ audit/divergences/sheet.yml
  ✅ Props      side: (:top/:right/:bottom/:left, default :right)
               RubyUI-only: Sheet(open:).
               ↳ bases/base/ui/sheet.tsx:42-46
  ✅ Props      show_close_button: (default true)
               ↳ bases/base/ui/sheet.tsx:43-47
  ✅ ARIA       role dialog (native), aria-labelledby → "Edit profile", aria-describedby → description; trigger aria-haspopup=dialog, aria-expanded, aria-controls while open
               Real Chrome snapshot: dialog "Edit profile" description="Make changes to your profile here." modal.
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Behavior   initial focus on Cancel (first tabbable)
               Real Chrome and Playwright.
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Behavior   Tab loops Cancel → Save changes → Close → Cancel; Shift+Tab reverses
               Real Chrome (chrome-devtools MCP).
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Behavior   Esc closes, focus back on the trigger, aria-expanded false
               Real Chrome.
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Behavior   outside click closes, focus back on the trigger
               ↳ bases/base/ui/sheet.tsx:50-52
  ✅ Behavior   Cancel (ruby-ui--sheet#dismiss) closes, focus back on the trigger
               ↳ bases/base/ui/sheet.tsx:63
  ✅ Behavior   scroll lock: body overflow hidden, wheel blocked (scrollY 200 → 600, only programmatic scrollBy)
               ↳ bases/base/ui/sheet.tsx:52
  ✅ Animation  panel fade + 2.5rem slide (slide-in-from-right-10), 200ms ease-in-out enter and exit; ::backdrop fade 150ms
               Keyframes (tw-animate) instead of transitions, same values; sampled with getAnimations().
               ↳ bases/base/ui/sheet.tsx:56
  ✅ Native     <dialog> + showModal() (top layer, inert page, Esc, initial focus); scroll lock via body overflow-hidden, exit animation held by the controller — native
               Nothing shadcn delivers is lost.
               ↳ https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
  ✅ Spec       role dialog with accessible name "Edit profile"
               WAI-ARIA 1.2 dialog: Accessible Name Required met.
               ↳ https://www.w3.org/TR/wai-aria-1.2/#dialog
  ✅ Spec       focus in, Esc closes, focus back to trigger, page behind inert
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction
  ✅ Spec       modal via showModal() (exposed as modal in the a11y tree) — follows spec
               RubyUI meets it natively; shadcn compensates with aria-hidden.
               ↳ https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#wai-ariaroles,states,andproperties

Follow-ups

  • Dialog (gem/lib/ruby_ui/dialog/dialog_controller.js) has the three lifecycle gaps this PR fixes in Sheet: no turbo:before-cache cleanup (a cached <dialog open> comes back without modality, and reopening throws InvalidStateError), listeners bound in connect() so a panel replaced by a Turbo Stream loses Esc/close handling, and overflow-hidden removed from <body> even while another modal is open. AlertDialog already handles all three. Out of scope here because it is another component.

Test steps

  1. cd gem && bundle exec rake (tests + standardrb).
  2. cd docs && bin/rails test, then open /docs/sheet: try the four sides, Tab (focus stays inside), Esc, a click outside and the Close/Cancel buttons. Focus should return to the button that opened the sheet.
  3. Open /docs/sidebar/example at a mobile width and press "Toggle Sidebar": a modal sidebar opens with focus on "Search", and Esc closes it.
  4. cd audit && bundle exec rake, and cd mcp && bundle exec rake test.

🤖 Generated with Claude Code