[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.
6 issues 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_controller.js">
<violation number="1" location="gem/lib/ruby_ui/sheet/sheet_controller.js:13">
P2: When one Sheet disconnects while another Sheet is still open, this unconditional cleanup restores body scrolling behind the remaining modal. Track the lock per open instance and remove `overflow-hidden` only when no Sheet still owns it.</violation>
</file>
<file name="gem/test/ruby_ui/sheet_test.rb">
<violation number="1" location="gem/test/ruby_ui/sheet_test.rb:76">
P3: test_close_button_closes and test_close_button_is_rendered_by_default assert the exact same regex on render_sheet, so the two tests cover one behavior with two names. Merge them into a single test (e.g. have test_close_button_closes additionally verify the corner button triggers the close action) so one of the pair isn't a silent duplicate.</violation>
</file>
<file name="gem/lib/ruby_ui/sheet/sheet_content_controller.js">
<violation number="1" location="gem/lib/ruby_ui/sheet/sheet_content_controller.js:14">
P2: When `SheetContent` is detached while its parent sheet remains connected, the removed `close` listener cannot unlock the body and scrolling stays disabled. Remove `overflow-hidden` during teardown as well.</violation>
<violation number="2" location="gem/lib/ruby_ui/sheet/sheet_content_controller.js:22">
P2: When a sheet is reopened and closed again before the prior `animationcancel` is delivered, the stale cancellation matches the new run and closes the dialog immediately. Track the specific animation run or animation objects instead of matching names alone.</violation>
</file>
<file name="docs/app/views/docs/sheet.rb">
<violation number="1" location="docs/app/views/docs/sheet.rb:49">
P3: The rewritten "Side" example drops the `<Form` wrapper (it used to wrap the fields and footer) but keeps `Button(type: "submit") { "Save" }`. Without a form, the Save submit button now renders inert — pressing it does nothing. Wrap the SheetMiddle/SheetFooter in a `Form` (as the previous example did) or change Save to a plain button so the copied example behaves as documented.</violation>
</file>
<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
| } | ||
|
|
||
| disconnect() { | ||
| document.body.classList.remove("overflow-hidden"); |
There was a problem hiding this comment.
P2: When one Sheet disconnects while another Sheet is still open, this unconditional cleanup restores body scrolling behind the remaining modal. Track the lock per open instance and remove overflow-hidden only when no Sheet still owns it.
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_controller.js, line 13:
<comment>When one Sheet disconnects while another Sheet is still open, this unconditional cleanup restores body scrolling behind the remaining modal. Track the lock per open instance and remove `overflow-hidden` only when no Sheet still owns it.</comment>
<file context>
@@ -1,15 +1,23 @@
+ }
+
+ disconnect() {
+ document.body.classList.remove("overflow-hidden");
}
</file context>
|
|
||
| this.element.dataset.state = "closed"; | ||
| // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one exit duration so either settles it. | ||
| this.hideAfterExitAnimation(this.element); |
There was a problem hiding this comment.
P2: When a sheet is reopened and closed again before the prior animationcancel is delivered, the stale cancellation matches the new run and closes the dialog immediately. Track the specific animation run or animation objects instead of matching names alone.
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 22:
<comment>When a sheet is reopened and closed again before the prior `animationcancel` is delivered, the stale cancellation matches the new run and closes the dialog immediately. Track the specific animation run or animation objects instead of matching names alone.</comment>
<file context>
@@ -1,24 +1,56 @@
+
+ this.element.dataset.state = "closed";
+ // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one exit duration so either settles it.
+ this.hideAfterExitAnimation(this.element);
}
</file context>
| this.element.removeEventListener("cancel", this.handleCancel); | ||
| this.element.removeEventListener("close", this.handleClose); | ||
| // Nothing is left to wait for the exit animation, so apply the pending close now. | ||
| this.settleExit(this.element); |
There was a problem hiding this comment.
P2: When SheetContent is detached while its parent sheet remains connected, the removed close listener cannot unlock the body and scrolling stays disabled. Remove overflow-hidden during teardown as well.
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 14:
<comment>When `SheetContent` is detached while its parent sheet remains connected, the removed `close` listener cannot unlock the body and scrolling stays disabled. Remove `overflow-hidden` during teardown as well.</comment>
<file context>
@@ -1,24 +1,56 @@
+ this.element.removeEventListener("cancel", this.handleCancel);
+ this.element.removeEventListener("close", this.handleClose);
+ // Nothing is left to wait for the exit animation, so apply the pending close now.
+ this.settleExit(this.element);
}
</file context>
| this.settleExit(this.element); | |
| this.settleExit(this.element); | |
| document.body.classList.remove("overflow-hidden"); |
| 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>
| end | ||
|
|
||
| def test_close_button_closes | ||
| assert_match(/<button[^>]*\sdata-action="click->ruby-ui--sheet-content#close"/, render_sheet) |
There was a problem hiding this comment.
P3: test_close_button_closes and test_close_button_is_rendered_by_default assert the exact same regex on render_sheet, so the two tests cover one behavior with two names. Merge them into a single test (e.g. have test_close_button_closes additionally verify the corner button triggers the close action) so one of the pair isn't a silent duplicate.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/test/ruby_ui/sheet_test.rb, line 76:
<comment>test_close_button_closes and test_close_button_is_rendered_by_default assert the exact same regex on render_sheet, so the two tests cover one behavior with two names. Merge them into a single test (e.g. have test_close_button_closes additionally verify the corner button triggers the close action) so one of the pair isn't a silent duplicate.</comment>
<file context>
@@ -43,12 +43,165 @@ def test_render_open_when_open_is_true
+ end
+
+ def test_close_button_closes
+ assert_match(/<button[^>]*\sdata-action="click->ruby-ui--sheet-content#close"/, render_sheet)
+ end
+
</file context>
| Button(variant: :outline, class: 'capitalize') { side.to_s } | ||
| end | ||
| Form do | ||
| SheetContent(side: side) do |
There was a problem hiding this comment.
P3: The rewritten "Side" example drops the <Form wrapper (it used to wrap the fields and footer) but keeps Button(type: "submit") { "Save" }. Without a form, the Save submit button now renders inert — pressing it does nothing. Wrap the SheetMiddle/SheetFooter in a Form (as the previous example did) or change Save to a plain button so the copied example behaves as documented.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/app/views/docs/sheet.rb, line 49:
<comment>The rewritten "Side" example drops the `<Form` wrapper (it used to wrap the fields and footer) but keeps `Button(type: "submit") { "Save" }`. Without a form, the Save submit button now renders inert — pressing it does nothing. Wrap the SheetMiddle/SheetFooter in a `Form` (as the previous example did) or change Save to a plain button so the copied example behaves as documented.</comment>
<file context>
@@ -38,27 +40,25 @@ def view_template
+ Button(variant: :outline, class: 'capitalize') { side.to_s }
end
- Form do
+ SheetContent(side: side) do
+ SheetHeader do
+ SheetTitle { "Edit profile" }
</file context>
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(). Same "Overlay exit" block as [Bug Fix] Overlays: play the exit animation before hiding #506, copied unchanged, and the same approach as [Bug Fix] Dialog: play the exit animation before closing the native <dialog> #517 / [Bug Fix] AlertDialog: render a native <dialog> and play the exit animation #518.::backdropanimation events are dispatched on the<dialog>under the same keyframe name, so the backdrop getsbackdrop:duration-300to end together with the panel's 300 ms exit — otherwise it settles the close early and cuts the panel short.<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::backdropwith nothing cancelled,closefires once, reopening mid-exit and a second Escape mid-exit (non-cancelable) settle correctly, 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
::backdropexit now lasts the same 300 ms as the panel, so the backdrop no longer cuts the close short.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 9ed43db. Summary will update on new commits.