-
Notifications
You must be signed in to change notification settings - Fork 66
[Bug Fix] Sheet: render a native <dialog> and close on Escape #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module RubyUI | ||
| class SheetClose < Base | ||
| def view_template(&) | ||
| div(**attrs, &) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| end | ||
|
|
||
| private | ||
|
|
||
| def default_attrs | ||
| { | ||
| data: {action: "click->ruby-ui--sheet-content#close"} | ||
| } | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,24 +1,56 @@ | ||||||||
| import { Controller } from "@hotwired/stimulus"; | ||||||||
|
|
||||||||
| // Connects to data-controller="ruby-ui--sheet-content" on the <dialog>; ruby-ui--sheet opens it. | ||||||||
| export default class extends Controller { | ||||||||
| static targets = ["backdrop", "panel"]; | ||||||||
| connect() { | ||||||||
| this.element.addEventListener("cancel", this.handleCancel); | ||||||||
| this.element.addEventListener("close", this.handleClose); | ||||||||
| } | ||||||||
|
|
||||||||
| disconnect() { | ||||||||
| // Nothing is left to wait for the exit animation, so apply the pending removal now. | ||||||||
| if (this.hasPanelTarget) this.settleExit(this.panelTarget); | ||||||||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Suggested change
|
||||||||
| } | ||||||||
|
|
||||||||
| close() { | ||||||||
| this.backdropTarget.dataset.state = "closed"; | ||||||||
| this.panelTarget.dataset.state = "closed"; | ||||||||
| // The panel carries the longer exit, so the backdrop has finished by the time it settles. | ||||||||
| this.hideAfterExitAnimation(this.panelTarget); | ||||||||
| if (this.element.dataset.state === "closed") return; | ||||||||
|
|
||||||||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a sheet is reopened and closed again before the prior Prompt for AI agents |
||||||||
| } | ||||||||
|
|
||||||||
| afterExit() { | ||||||||
| this.element.remove(); | ||||||||
| this.element.close(); | ||||||||
| } | ||||||||
|
|
||||||||
| // A click on the ::backdrop targets the dialog, but so does one on the panel's own padding: hit-test the box. | ||||||||
| backdropClick(e) { | ||||||||
| if (e.target === this.element && !this.coversPoint(e.clientX, e.clientY)) this.close(); | ||||||||
| } | ||||||||
|
|
||||||||
| coversPoint(x, y) { | ||||||||
| const { top, right, bottom, left } = this.element.getBoundingClientRect(); | ||||||||
| return left <= x && x <= right && top <= y && y <= bottom; | ||||||||
| } | ||||||||
|
|
||||||||
| // Escape (and requestClose()) fire cancel; route it through the exit animation. | ||||||||
| handleCancel = (e) => { | ||||||||
| // A cancelled file picker inside the sheet bubbles its own cancel event. | ||||||||
| if (e.target !== this.element) return; | ||||||||
|
|
||||||||
| e.preventDefault(); | ||||||||
| this.close(); | ||||||||
| }; | ||||||||
|
|
||||||||
| handleClose = () => { | ||||||||
| document.body.classList.remove("overflow-hidden"); | ||||||||
| // A close this controller did not start (a second Escape mid-exit) must not leave the exit listeners behind. | ||||||||
| this.settleExit(this.element); | ||||||||
| }; | ||||||||
|
|
||||||||
| // Overlay exit — the same block in every overlay controller, so keep them in sync. | ||||||||
| exitAnimationNames = new WeakMap(); | ||||||||
|
|
||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,23 @@ | ||
| import { Controller } from "@hotwired/stimulus" | ||
| import { Controller } from "@hotwired/stimulus"; | ||
|
|
||
| // Connects to data-controller="ruby-ui--sheet"; opens the <dialog> that ruby-ui--sheet-content closes. | ||
| export default class extends Controller { | ||
| static targets = ["content"] | ||
|
|
||
| static values = { open: false } | ||
| static targets = ["dialog"]; | ||
| static values = { open: false }; | ||
|
|
||
| connect() { | ||
| if (this.openValue) this.open() | ||
| if (this.openValue) this.open(); | ||
| } | ||
|
|
||
| disconnect() { | ||
| document.body.classList.remove("overflow-hidden"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| } | ||
|
|
||
| open() { | ||
| document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML) | ||
| open(e) { | ||
| e?.preventDefault(); | ||
| this.dialogTarget.dataset.state = "open"; | ||
| // Reopened mid-exit the dialog is still open; showModal() on an open dialog throws in older browsers. | ||
| if (!this.dialogTarget.open) this.dialogTarget.showModal(); | ||
| document.body.classList.add("overflow-hidden"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
<Formwrapper (it used to wrap the fields and footer) but keepsButton(type: "submit") { "Save" }. Without a form, the Save submit button now renders inert — pressing it does nothing. Wrap the SheetMiddle/SheetFooter in aForm(as the previous example did) or change Save to a plain button so the copied example behaves as documented.Prompt for AI agents