[Bug Fix] Sheet: render a native <dialog> and close on Escape - #520
Conversation
Sheet was the last overlay built on the old pattern: a <template> cloned
onto <body> with insertAdjacentHTML, wrapped in plain divs. That shape has
no Escape handling, no focus trap, no inert background and no focus
restore, and re-cloning the panel on every open threw away whatever the
user had typed into it.
In shadcn all four registry variants build Sheet out of the Dialog
primitive ("Extends the Dialog component"), which is where those
behaviours come from. SheetContent is now a native <dialog> opened with
showModal(), so the browser provides them. The cancel event is intercepted
so Escape plays the exit animation before the dialog actually closes,
matching Dialog (ruby-ui#517) and AlertDialog (ruby-ui#518) and reusing their exit block.
Since the component was rewritten anyway, the gaps against the shadcn API
are closed here too rather than in a follow-up: a SheetClose wrapper, a
show_close_button: option (which replaces the [&>button]:hidden hack in
MobileSidebar), the default w-3/4 sm:max-w-sm width for the left and right
sides, and data-side on the panel so a caller can target one side.
A modal <dialog> is pinned to every edge by inset: 0, so each side now
releases the opposite one and the box gets m-0/max-w-full/max-h-full.
not-open:hidden guards the UA display: none against a caller passing a
bare flex. The backdrop's exit lasts as long as the panel's because its
animationend is dispatched on the <dialog> under the same keyframe name.
Clicking the backdrop closes; clicking the panel's own padding, which
targets the same element, does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 9 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/sheet/sheet_close.rb">
<violation number="1" location="gem/lib/ruby_ui/sheet/sheet_close.rb:6">
P2: SheetClose renders a non-interactive <div> as the close control, so a bare SheetClose with no button child is not focusable, not keyboard-activatable, and not announced by screen readers. This PR's goal is alignment with the shadcn Sheet API, where SheetClose is a <button>, and the component's own close_button is already a real <button>. Render a <button type="button"> so the wrapper is interactive regardless of its contents.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| module RubyUI | ||
| class SheetClose < Base | ||
| def view_template(&) | ||
| div(**attrs, &) |
There was a problem hiding this comment.
P2: SheetClose renders a non-interactive
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/sheet/sheet_close.rb, line 6:
<comment>SheetClose renders a non-interactive <div> as the close control, so a bare SheetClose with no button child is not focusable, not keyboard-activatable, and not announced by screen readers. This PR's goal is alignment with the shadcn Sheet API, where SheetClose is a <button>, and the component's own close_button is already a real <button>. Render a <button type="button"> so the wrapper is interactive regardless of its contents.</comment>
<file context>
@@ -0,0 +1,17 @@
+module RubyUI
+ class SheetClose < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
</file context>
There was a problem hiding this comment.
Keeping the <div>. SheetClose follows this library's wrapper convention: SheetTrigger, DialogTrigger, PopoverTrigger (and DrawerClose in #519) are <div>s carrying the action, with the caller supplying the Button. That is the usage every docs example shows (SheetClose { Button(variant: :outline) { "Cancel" } }), the equivalent of shadcn's asChild. Rendering a <button> here would nest a button inside a button in that documented usage, which is invalid HTML. A bare SheetClose { "Cancel" } is as non-interactive as a bare SheetTrigger { "Open" }; the wrapper is the API.
Review follow-ups on ruby-ui#520: - The body scroll lock was lifted unconditionally, by `ruby-ui--sheet` on disconnect and by `ruby-ui--sheet-content` on close. A Sheet disconnecting behind another open Sheet (or a Dialog) unlocked the page under the remaining modal, and a <dialog> removed while open fires no close event, so its lock outlived it. The release now lives in the content controller alone, guarded by `dialog:modal`: the class goes only when no modal <dialog> is left in the document. The wrapper's `disconnect()` is gone; the content controller disconnects whenever the wrapper does, and also when only the <dialog> is replaced. - Merge the two identical corner-button tests; the survivor also checks `type="button"`, so the button cannot submit a form around the content. - Docs: Save is a plain Button in both examples, as in the Dialog docs; a submit button without a form did nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
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/sheet/sheet_content_controller.js">
<violation number="1" location="gem/lib/ruby_ui/sheet/sheet_content_controller.js:16">
P2: When Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves `overflow-hidden` on the body. Close the still-open dialog during teardown, or exclude the disconnected element from the modal check while retaining checks for other dialogs.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| // Nothing is left to wait for the exit animation, so apply the pending close now. | ||
| this.settleExit(this.element); | ||
| // Removed while open, the dialog fires no close event; the lock must not outlive it. | ||
| this.releaseScrollLock(); |
There was a problem hiding this comment.
P2: When Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves overflow-hidden on the body. Close the still-open dialog during teardown, or exclude the disconnected element from the modal check while retaining checks for other dialogs.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/sheet/sheet_content_controller.js, line 16:
<comment>When Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves `overflow-hidden` on the body. Close the still-open dialog during teardown, or exclude the disconnected element from the modal check while retaining checks for other dialogs.</comment>
<file context>
@@ -12,6 +12,8 @@ export default class extends Controller {
// Nothing is left to wait for the exit animation, so apply the pending close now.
this.settleExit(this.element);
+ // Removed while open, the dialog fires no close event; the lock must not outlive it.
+ this.releaseScrollLock();
}
</file context>
…luded
The exit block matched animationend/animationcancel by keyframe name,
which left two holes on a native <dialog>:
- Reopened mid-exit and closed again before the next frame, the first
run's animationcancel still matched "exit" and closed the dialog under
the second run (25 ms instead of 300 ms in headless Chrome).
- getAnimations() does not list ::backdrop, so the block only knew the
panel while the backdrop's events arrived under the same name;
whichever ended first closed the dialog and cut the other as soon as
the durations differed (a duration-* override on SheetContent).
close() now collects the panel's and the backdrop's CSSAnimations
(getAnimations({subtree: true}) filtered to effect.target === dialog),
waits on their `finished` promises and settles only when no later close
superseded the run. A second Escape mid-exit is no longer
preventDefault()ed: Chrome makes it non-cancelable anyway, so browsers
that keep it cancelable now close at once as well. Same block as Dialog
in ruby-ui#517. backdrop:duration-300 stays so both fade in step by default;
the controller no longer depends on the two matching.
Addresses cubic's thread on sheet_content_controller.js in ruby-ui#520.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports two review fixes from the sibling PRs: - disconnect() reached this.dialogTarget unconditionally; a <dialog> removed before its wrapper made Stimulus throw and left the body locked (ruby-ui#518). The target-specific teardown is guarded with hasDialogTarget, the lock is lifted either way. - The body class was removed unconditionally on close and disconnect. A Dialog closed or removed on top of another modal <dialog> (a nested Dialog, a Sheet) unlocked the page under the remaining one (ruby-ui#520). The release is now guarded by `dialog:modal`: the class goes only when no modal <dialog> is left in the document. Verified in headless Chrome: dialog removed before its wrapper — no error, lock lifted; second Dialog closed or removed on top of the first — lock kept while the first is modal, released with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem
SheetContentrendered a<template>that the controller cloned to the end of<body>on open: no Escape, no focus trap/restore, the page behind stayed interactive, and re-cloning the panel threw away anything the user had typed into it.SheetClose, noshowCloseButtonequivalent (soMobileSidebarhid the corner button with a[&>button]:hiddenhack), no default width (every docs example had to passsm:max-w-smby hand), and nodata-sideto target one side from the outside.Change
<dialog>rendered in place, opened withshowModal()— top layer, inert background, focus trap and focus return from the platform. No template/clone. In shadcn all four registry variants build Sheet from the Dialog primitive ("Extends the Dialog component"), which is where these come from there.cancelis intercepted so the exit animates first, thendialog.close(). The controller waits on the exit animations themselves, as Dialog does in [Bug Fix] Dialog: play the exit animation before closing the native <dialog> #517:close()setsdata-state="closed", collects the panel's and the::backdrop'sCSSAnimations (getAnimations({subtree: true})on the<dialog>, filtered to its owneffect.target), waits on theirfinishedpromises with a token per run so a superseded run cannot settle a later one, then callsclose(). This differs from theanimationendblock of [Bug Fix] Overlays: play the exit animation before hiding #506 becausegetAnimations()alone never lists::backdropand the backdrop's events land on the<dialog>under the same keyframe names: that block closed on whichever exit ended first, and a staleanimationcancelfrom a previous run could close a new one early. A second Escape mid-exit is left to the browser (non-cancelable in Chrome anyway); a close the controller did not start settles the pending run.backdrop:duration-300so it fades out in step with the panel's 300 ms exit (--tw-durationis registeredinherits: false, so::backdropdoes not pick the panel's duration up). The controller does not depend on the two matching: aduration-*override onSheetContentcloses after the longer of the two.<dialog>is left open (dialog:modal), so a Sheet nested in a Dialog, or one disconnecting behind another, keeps the page locked.<dialog>a click on the panel's own padding targets the same element, sobackdropClickhit-tests the panel box and ignores it.<dialog>resets: a modal dialog is pinned to all four edges byinset: 0, so each side releases the opposite one, plusm-0 max-w-full max-h-full.not-open:hiddenkeeps a caller's bareflexfrom overriding the UAdisplay: nonewhen closed — the docs' own theme sheet passes one.SheetClosewrapper,show_close_button:onSheetContent(replaces the[&>button]:hiddenhack), defaultw-3/4 sm:max-w-smfor left/right, anddata-sideon the panel. A caller's own classes still win throughtailwind_merge.ruby-ui--sheet#openon the wrapper,ruby-ui--sheet-content#closeon the dialog, so the public action strings apps already have in their markup keep working.bg-background/80 backdrop-blur-sm(consistent with Dialog/AlertDialog, not shadcn'sbg-black/50), and header/footer padding stays on the panel — moving it would reflow every existing sheet.mcp/data/registry.jsonrebuilt (separate commit).Test
cd gem && bundle exec rake— 15 tests insheet_test.rb.animationendat 300 ms on both panel and::backdropandclosefires once; reopen mid-exit comes back; reopen + close again inside one frame still plays the full 300 ms exit (25 ms before the controller change); aduration-*override on the panel or the backdrop closes after the longer one;requestClose()mid-exit closes at once and a second Escape mid-exit (non-cancelable) settles; removal mid-exit and an externalclose()both leave scroll unlocked; each side lands on its edge, and the default width resolves tomin(75vw, 24rem).🤖 Generated with Claude Code
Summary by cubic
Renders
SheetContentas a native<dialog>opened withshowModal()instead of cloning a<template>to the end of<body>— the page behind is now inert, focus is trapped and restored, Escape closes the sheet, and reopening no longer throws away typed content. The existingruby-ui--sheet#openandruby-ui--sheet-content#closeaction strings keep working unchanged.Bug Fixes
::backdrop's animationfinishedpromises, so a stale exit run from reopening mid-close can't close the dialog early, and a mismatched duration can't cut either exit short.<dialog>is left open, so one sheet closing behind another no longer unlocks the page.New Features
SheetClosewrapper and ashow_close_button:option onSheetContent;MobileSidebarnow usesshow_close_button: falseinstead of its[&>button]:hiddenhack.w-3/4 sm:max-w-smwidth, and the panel exposesdata-sidefor targeting one side from outside.Written for commit 3d7d85c. Summary will update on new commits.