-
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
021005c
9ed43db
d790b08
ea5ef2d
583aa9e
3d7d85c
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, &) | ||
|
tvq marked this conversation as resolved.
|
||
| 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,55 +1,92 @@ | ||
| 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); | ||
|
tvq marked this conversation as resolved.
|
||
| // Removed while open, the dialog fires no close event; the lock must not outlive it. | ||
| this.releaseScrollLock(); | ||
|
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 Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves Prompt for AI agents |
||
| } | ||
|
|
||
| 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"; | ||
| this.hideAfterExitAnimation(this.element); | ||
|
tvq marked this conversation as resolved.
|
||
| } | ||
|
|
||
| afterExit() { | ||
| this.element.remove(); | ||
| this.element.close(); | ||
| } | ||
|
|
||
| // Overlay exit — the same block in every overlay controller, so keep them in sync. | ||
| exitAnimationNames = new WeakMap(); | ||
| // 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; | ||
| // Already on its way out: let a second Escape close natively where the browser allows it. | ||
| if (this.element.dataset.state === "closed") return; | ||
|
|
||
| e.preventDefault(); | ||
| this.close(); | ||
| }; | ||
|
|
||
| handleClose = () => { | ||
| this.releaseScrollLock(); | ||
| // A close this controller did not start (a second Escape mid-exit) must not leave a pending exit behind. | ||
| this.settleExit(this.element); | ||
| }; | ||
|
|
||
| // A nested Sheet or a Dialog may still be open underneath; the page stays locked for it. | ||
| releaseScrollLock() { | ||
| if (document.querySelector("dialog:modal")) return; | ||
|
|
||
| document.body.classList.remove("overflow-hidden"); | ||
| } | ||
|
|
||
| // Overlay exit — unlike the other overlays this waits on the Animation objects: the ::backdrop animates too, | ||
| // and its events land on the <dialog> under the same keyframe names as the panel's. | ||
| hideAfterExitAnimation(animated) { | ||
| const run = (this.exitRun = {}); | ||
| // subtree: true is what lists the ::backdrop's animation; descendants are filtered back out. | ||
| const exitAnimations = animated | ||
| .getAnimations() | ||
| .filter((animation) => animation instanceof CSSAnimation); | ||
| .getAnimations({ subtree: true }) | ||
| .filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated); | ||
|
|
||
| // No exit animation, or no box to run it in: animationend would never fire. | ||
| // No exit animation, or no box to run it in: nothing would ever finish. | ||
| if (exitAnimations.length === 0) { | ||
| this.settleExit(animated); | ||
| return; | ||
| } | ||
|
|
||
| this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); | ||
| animated.addEventListener("animationend", this.handleExitAnimationEnd); | ||
| animated.addEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| } | ||
| // A cancelled exit (reopened mid-exit) counts as finished too. | ||
| Promise.allSettled(exitAnimations.map((animation) => animation.finished)).then(() => { | ||
| // A later close owns the dialog now; this run is stale. | ||
| if (this.exitRun !== run) return; | ||
|
|
||
| handleExitAnimationEnd = (event) => { | ||
| // animationend bubbles — an animated child must not hide its container. | ||
| if (event.target !== event.currentTarget) return; | ||
| // Closing mid-open cancels the enter animation; only the exit run settles this. | ||
| if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; | ||
|
|
||
| this.settleExit(event.currentTarget); | ||
| }; | ||
| this.settleExit(animated); | ||
| }); | ||
| } | ||
|
|
||
| settleExit(animated) { | ||
| animated.removeEventListener("animationend", this.handleExitAnimationEnd); | ||
| animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| this.exitRun = null; | ||
| // Reopened mid-exit: it is on its way back in, leave it visible. | ||
| if (animated.dataset.state !== "closed") return; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,19 @@ | ||
| 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(); | ||
| } | ||
|
|
||
| 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"); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.