[Feature] Sheet: match shadcn (Base UI + vega)
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'sshowCloseButton).- New markup closes with
click->ruby-ui--sheet#dismiss.click->ruby-ui--sheet-content#closekeeps working through a small shim controller, so existing apps don't break. - SheetMiddle is now
px-4(it waspy-4), because the panel no longer hasp-6of its own. It mirrors the<div className="px-4">body in shadcn's examples. - A caller class like
flexno longer shows a closed sheet: the panel hasnot-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



Mobile sidebar (this PR, 390px):
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: noturbo:before-cachecleanup (a cached<dialog open>comes back without modality, and reopening throwsInvalidStateError), listeners bound inconnect()so a panel replaced by a Turbo Stream loses Esc/close handling, andoverflow-hiddenremoved from<body>even while another modal is open. AlertDialog already handles all three. Out of scope here because it is another component.
Test steps
cd gem && bundle exec rake(tests + standardrb).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.- Open
/docs/sidebar/exampleat a mobile width and press "Toggle Sidebar": a modal sidebar opens with focus on "Search", and Esc closes it. cd audit && bundle exec rake, andcd mcp && bundle exec rake test.
🤖 Generated with Claude Code