Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 66
[Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before hiding#506
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?
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -24,6 +24,8 @@ export default class extends Controller { | ||
| disconnect() { | ||
| this.hide(); | ||
| // Nothing is left to wait for the exit animation, so apply the pending hide now. | ||
| if (this.hasContentTarget) this.settleExit(this.contentTarget); | ||
| } | ||
| handleContextMenu(event) { | ||
| @@ -49,8 +51,8 @@ export default class extends Controller { | ||
| hide() { | ||
| if (!this.openValue) return; | ||
| this.openValue = false; | ||
| this.contentTarget.classList.add("hidden"); | ||
| this.contentTarget.dataset.state = "closed"; | ||
| this.hideAfterExitAnimation(); | ||
| this.removeEventListeners(); | ||
| this.deselectAll(); | ||
| if (this.cleanup) { | ||
| @@ -59,6 +61,39 @@ export default class extends Controller { | ||
| } | ||
| } | ||
| hideAfterExitAnimation() { | ||
| const content = this.contentTarget; | ||
| const styles = getComputedStyle(content); | ||
| // An element with no exit animation never fires animationend. | ||
| if (styles.animationName === "none" || styles.display === "none") { | ||
| this.settleExit(content); | ||
| return; | ||
| } | ||
| this.exitAnimationNames = styles.animationName.split(",").map((name) => name.trim()); | ||
| content.addEventListener("animationend", this.handleExitAnimationEnd); | ||
| content.addEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| } | ||
| handleExitAnimationEnd = (event) => { | ||
| // animationend bubbles — an animated child must not hide its container. | ||
| if (event.target !== event.currentTarget) return; | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Closing mid-open cancels the enter animation; only the exit run settles this. | ||
| if (!this.exitAnimationNames.includes(event.animationName)) return; | ||
| this.settleExit(event.currentTarget); | ||
| }; | ||
| settleExit(content) { | ||
| content.removeEventListener("animationend", this.handleExitAnimationEnd); | ||
| content.removeEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| // Reopened mid-exit: it is on its way back in, leave it visible. | ||
| if (content.dataset.state !== "closed") return; | ||
| content.classList.add("hidden"); | ||
| } | ||
| updatePosition() { | ||
| if (this.cleanup) this.cleanup(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -35,6 +35,8 @@ export default class extends Controller { | ||
| this.cleanup(); | ||
| this.cleanup = null; | ||
| } | ||
| // Nothing is left to wait for the exit animation, so apply the pending hide now. | ||
| if (this.hasContentTarget) this.settleExit(this.contentTarget); | ||
| } | ||
| // Supports the tippy-style `delay` option: a number or a [open, close] tuple. | ||
| @@ -95,8 +97,8 @@ export default class extends Controller { | ||
| hide() { | ||
| this.openValue = false; | ||
| this.contentTarget.classList.add("hidden"); | ||
| this.contentTarget.dataset.state = "closed"; | ||
| this.hideAfterExitAnimation(); | ||
| document.removeEventListener("keydown", this.boundHandleKeydown); | ||
| this.deselectAll(); | ||
| if (this.cleanup) { | ||
| @@ -105,6 +107,39 @@ export default class extends Controller { | ||
| } | ||
| } | ||
| hideAfterExitAnimation() { | ||
| const content = this.contentTarget; | ||
| const styles = getComputedStyle(content); | ||
| // An element with no exit animation never fires animationend. | ||
| if (styles.animationName === "none" || styles.display === "none") { | ||
| this.settleExit(content); | ||
| return; | ||
| } | ||
| this.exitAnimationNames = styles.animationName.split(",").map((name) => name.trim()); | ||
| content.addEventListener("animationend", this.handleExitAnimationEnd); | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| content.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.includes(event.animationName)) return; | ||
| this.settleExit(event.currentTarget); | ||
| }; | ||
| settleExit(content) { | ||
| content.removeEventListener("animationend", this.handleExitAnimationEnd); | ||
| content.removeEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| // Reopened mid-exit: it is on its way back in, leave it visible. | ||
| if (content.dataset.state !== "closed") return; | ||
| content.classList.add("hidden"); | ||
| } | ||
| updatePosition() { | ||
| if (this.cleanup) this.cleanup(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -33,6 +33,8 @@ export default class extends Controller { | ||
| document.removeEventListener("click", this.handleOutsideClick); | ||
| this.stopAutoUpdate(); | ||
| this.removeElementEventListeners(); | ||
| // Nothing is left to wait for the exit animation, so apply the pending hide now. | ||
| if (this.hasContentTarget) this.settleExit(this.contentTarget); | ||
| } | ||
| addEventListeners() { | ||
| @@ -112,8 +114,41 @@ export default class extends Controller { | ||
| if (!this.hasContentTarget) return; | ||
| this.contentTarget.classList.add("hidden"); | ||
| this.contentTarget.dataset.state = "closed"; | ||
| this.hideAfterExitAnimation(); | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| hideAfterExitAnimation() { | ||
| const content = this.contentTarget; | ||
| const styles = getComputedStyle(content); | ||
| // An element with no exit animation never fires animationend. | ||
| if (styles.animationName === "none" || styles.display === "none") { | ||
| this.settleExit(content); | ||
| return; | ||
| } | ||
| this.exitAnimationNames = styles.animationName.split(",").map((name) => name.trim()); | ||
| content.addEventListener("animationend", this.handleExitAnimationEnd); | ||
| content.addEventListener("animationcancel", this.handleExitAnimationEnd); | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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.includes(event.animationName)) return; | ||
| this.settleExit(event.currentTarget); | ||
| }; | ||
| settleExit(content) { | ||
| content.removeEventListener("animationend", this.handleExitAnimationEnd); | ||
| content.removeEventListener("animationcancel", this.handleExitAnimationEnd); | ||
| // Reopened mid-exit: it is on its way back in, leave it visible. | ||
| if (content.dataset.state !== "closed") return; | ||
| content.classList.add("hidden"); | ||
| } | ||
| updatePosition() { | ||
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
prefers-reduced-motion: reduceis active, this controller still waits for theanimate-outevent because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation namenone, so the menu hides immediately.Prompt for AI agents
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.
Checked this one before acting on it, and I'd rather not take the suggestion — but the premise is right.
grep -c prefers-reduced-motionon the docs app's builtapplication.cssreturns 0: the library ships no reduced-motion rules at all, sotw-animate-cssanimations run regardless of the setting. Where I think the conclusion differs is the consequence — the exit animation still runs andanimationendstill fires, so nothing hangs. Reduced-motion users see the same exit they already see on enter.Adding
matchMediaonly here would make the close jump while the open still animates, which reads worse than the current consistency. And reduced-motion support looks library-wide to me — everyanimate-inin the overlay family, not just these three exits — so I'd rather not smuggle a partial version in under a bug fix. I've noted it in the PR description and am happy to open a separate issue for it.The computed
animation-namecheck earns its place for the cases it does cover: notw-animate-cssinstalled, overridden classes, or a consumer's own reduced-motion CSS.