Skip to content

[Bug Fix] Sheet: render a native <dialog> and close on Escape - #520

Open
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:fix_sheet
Open

[Bug Fix] Sheet: render a native <dialog> and close on Escape#520
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:fix_sheet

Conversation

@tvq

@tvq tvq commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Problem

  • SheetContent rendered 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.
  • Gaps against the shadcn Sheet API: no SheetClose, no showCloseButton equivalent (so MobileSidebar hid the corner button with a [&>button]:hidden hack), no default width (every docs example had to pass sm:max-w-sm by hand), and no data-side to target one side from the outside.

Change

  • Native <dialog> rendered in place, opened with showModal() — 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.
  • Escape closes: cancel is intercepted so the exit animates first, then dialog.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.
  • ::backdrop animation events are dispatched on the <dialog> under the same keyframe name, so the backdrop gets backdrop:duration-300 to end together with the panel's 300 ms exit — otherwise it settles the close early and cuts the panel short.
  • Backdrop click closes (shadcn parity). On a <dialog> a click on the panel's own padding targets the same element, so backdropClick hit-tests the panel box and ignores it.
  • UA <dialog> resets: a modal dialog is pinned to all four edges by inset: 0, so each side releases the opposite one, plus m-0 max-w-full max-h-full. not-open:hidden keeps a caller's bare flex from overriding the UA display: none when closed — the docs' own theme sheet passes one.
  • API parity, closed here rather than in a follow-up since the component was being rewritten anyway: SheetClose wrapper, show_close_button: on SheetContent (replaces the [&>button]:hidden hack), default w-3/4 sm:max-w-sm for left/right, and data-side on the panel. A caller's own classes still win through tailwind_merge.
  • Both controllers stay: ruby-ui--sheet#open on the wrapper, ruby-ui--sheet-content#close on the dialog, so the public action strings apps already have in their markup keep working.
  • Docs: the Side example now shows all four sides, plus a new "No close button" section.
  • Unchanged on purpose: backdrop stays bg-background/80 backdrop-blur-sm (consistent with Dialog/AlertDialog, not shadcn's bg-black/50), and header/footer padding stays on the panel — moving it would reflow every existing sheet.
  • mcp/data/registry.json rebuilt (separate commit).

Test

  • cd gem && bundle exec rake — 15 tests in sheet_test.rb.
  • Manual, on the Sheet docs page:
    1. Open → panel slides in over 500 ms, backdrop fades in, page scroll is locked.
    2. Escape / the corner X / the footer's Cancel → exit animation plays, then it closes, focus returns to the trigger, scroll is restored.
    3. Click the backdrop → closes. Click the panel's own padding next to the content → stays open.
    4. All four sides, the "No close button" example (Escape and the footer Close still work), and the mobile menu on a narrow viewport.
  • Timing and geometry checked in headless Chrome (CDP): exit reaches animationend at 300 ms on both panel and ::backdrop with nothing cancelled, close fires 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 to min(75vw, 24rem).

🤖 Generated with Claude Code


Summary by cubic

Renders SheetContent as a native <dialog> opened with showModal() 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 existing ruby-ui--sheet#open and ruby-ui--sheet-content#close action strings keep working unchanged.

Bug Fixes

  • Escape is intercepted so the exit animation plays before the dialog closes; a second Escape mid-exit settles correctly.
  • The ::backdrop exit now lasts the same 300 ms as the panel, so the backdrop no longer cuts the close short.
  • Backdrop clicks close the sheet; clicks on the panel's own padding do not.

New Features

  • Adds a SheetClose wrapper and a show_close_button: option on SheetContent; MobileSidebar now uses show_close_button: false instead of its [&>button]:hidden hack.
  • Left and right sides get a default w-3/4 sm:max-w-sm width, and the panel exposes data-side for targeting one side from outside.

Written for commit 9ed43db. Summary will update on new commits.

Review in cubic

tvq and others added 2 commits August 29, 2026 12:33
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>
@tvq
tvq requested a review from cirdes as a code owner August 29, 2026 10:38

@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.

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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Suggested change
this.settleExit(this.element);
this.settleExit(this.element);
document.body.classList.remove("overflow-hidden");

module RubyUI
class SheetClose < Base
def view_template(&)
div(**attrs, &)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: SheetClose renders a non-interactive

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 , and the component's own close_button is already a real . Render a so the wrapper is interactive regardless of its contents.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

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