-
Notifications
You must be signed in to change notification settings - Fork 66
[Bug Fix] Dialog: play the exit animation before closing the native <dialog> #517
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
6cba4b0
f52f8b8
88fc170
9abe94c
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 |
|---|---|---|
| @@ -1,44 +1,106 @@ | ||
| import { Controller } from "@hotwired/stimulus" | ||
| import { Controller } from "@hotwired/stimulus"; | ||
|
|
||
| // Connects to data-controller="ruby-ui--dialog" | ||
| export default class extends Controller { | ||
| static targets = ["dialog"] | ||
| static targets = ["dialog"]; | ||
| static values = { | ||
| open: { | ||
| type: Boolean, | ||
| default: false | ||
| default: false, | ||
| }, | ||
| } | ||
| }; | ||
|
|
||
| connect() { | ||
| this.dialogTarget.addEventListener("close", this.handleClose) | ||
| this.dialogTarget.addEventListener("close", this.handleClose); | ||
| this.dialogTarget.addEventListener("cancel", this.handleCancel); | ||
| if (this.openValue) { | ||
| this.open() | ||
| this.open(); | ||
| } | ||
| } | ||
|
|
||
| disconnect() { | ||
| this.dialogTarget.removeEventListener("close", this.handleClose) | ||
| document.body.classList.remove("overflow-hidden") | ||
| this.dialogTarget.removeEventListener("close", this.handleClose); | ||
| this.dialogTarget.removeEventListener("cancel", this.handleCancel); | ||
| // Nothing is left to wait for the exit animation, so apply the pending close now. | ||
| this.settleExit(this.dialogTarget); | ||
| document.body.classList.remove("overflow-hidden"); | ||
| } | ||
|
|
||
| open(e) { | ||
| e?.preventDefault() | ||
| this.dialogTarget.showModal() | ||
| document.body.classList.add("overflow-hidden") | ||
| 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"); | ||
| } | ||
|
|
||
| dismiss() { | ||
| this.dialogTarget.close() | ||
| if (this.dialogTarget.dataset.state === "closed") return; | ||
|
|
||
| this.dialogTarget.dataset.state = "closed"; | ||
| // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one duration so either settles it. | ||
| this.hideAfterExitAnimation(this.dialogTarget); | ||
| } | ||
|
|
||
| afterExit() { | ||
| this.dialogTarget.close(); | ||
| } | ||
|
|
||
| backdropClick(e) { | ||
| if (e.target === this.dialogTarget) { | ||
| this.dismiss() | ||
| this.dismiss(); | ||
| } | ||
| } | ||
|
|
||
| // Escape (and requestClose()) fire cancel; route it through the exit animation. | ||
| handleCancel = (e) => { | ||
| // A cancelled file picker inside the dialog bubbles its own cancel event. | ||
| if (e.target !== this.dialogTarget) return; | ||
|
|
||
| e.preventDefault(); | ||
| this.dismiss(); | ||
| }; | ||
|
|
||
| handleClose = () => { | ||
| document.body.classList.remove("overflow-hidden") | ||
| 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.dialogTarget); | ||
| }; | ||
|
|
||
| // Overlay exit — the same block in every overlay controller, so keep them in sync. | ||
| exitAnimationNames = new WeakMap(); | ||
|
|
||
| hideAfterExitAnimation(animated) { | ||
| const exitAnimations = animated | ||
| .getAnimations() | ||
|
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: Prompt for AI agents |
||
| .filter((animation) => animation instanceof CSSAnimation); | ||
|
|
||
| // No exit animation, or no box to run it in: animationend would never fire. | ||
| if (exitAnimations.length === 0) { | ||
| this.settleExit(animated); | ||
| return; | ||
| } | ||
|
|
||
| this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); | ||
|
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. P1: When a dialog is reopened and dismissed again before the prior animation event is delivered, the stale Prompt for AI agents |
||
| animated.addEventListener("animationend", this.handleExitAnimationEnd); | ||
| animated.addEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| } | ||
|
|
||
| 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); | ||
| }; | ||
|
|
||
| settleExit(animated) { | ||
| animated.removeEventListener("animationend", this.handleExitAnimationEnd); | ||
| animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| // Reopened mid-exit: it is on its way back in, leave it visible. | ||
| if (animated.dataset.state !== "closed") return; | ||
|
|
||
| this.afterExit(animated); | ||
| } | ||
| } | ||
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.
P2: When a second Escape arrives during an existing exit, this unconditional
preventDefault()blocks browsers that expose thatcancelevent as cancelable. Return before preventing the event whendata-stateis alreadyclosed.Prompt for AI agents