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: set data-state/data-side, clear closeTimeout on disconnect, close on Escape#495
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
96047cdf73269cf91e8c9c86a8d5404c15dFile 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 |
|---|---|---|
| @@ -19,13 +19,20 @@ export default class extends Controller { | ||
| this.closeTimeout = null; | ||
| this.cleanup = null; | ||
| this.addEventListeners(); | ||
| // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element) | ||
| // arrives already open. Re-arm the parts that live on the controller instead of | ||
| // the markup — the keydown listener and the autoUpdate positioning. | ||
| if (this.openValue) this.showPopover(); | ||
| } | ||
| // Teardown that cannot fail comes first: resolving a target throws once the | ||
| // element is gone, and Stimulus swallows that, skipping the rest of disconnect. | ||
| disconnect() { | ||
| this.removeEventListeners(); | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| clearTimeout(this.closeTimeout); | ||
| document.removeEventListener("keydown", this.handleKeydown); | ||
| document.removeEventListener("click", this.handleOutsideClick); | ||
| this.stopAutoUpdate(); | ||
| this.removeElementEventListeners(); | ||
| } | ||
| addEventListeners() { | ||
| @@ -40,68 +47,124 @@ export default class extends Controller { | ||
| } | ||
| } | ||
| removeEventListeners() { | ||
| this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.triggerTarget.removeEventListener("click", this.handleClick); | ||
| document.removeEventListener("click", this.handleOutsideClick); | ||
| // Each target is guarded on its own: losing one of them must not strand the | ||
| // listeners attached to the other. | ||
| removeElementEventListeners() { | ||
| if (this.hasTriggerTarget) { | ||
| this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.triggerTarget.removeEventListener("click", this.handleClick); | ||
| } | ||
| if (this.hasContentTarget) { | ||
| this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| } | ||
| } | ||
| handleMouseEnter = () => { | ||
| clearTimeout(this.closeTimeout); | ||
| this.openValue = true; | ||
| this.showPopover(); | ||
| }; | ||
| handleMouseLeave = () => { | ||
| this.closeTimeout = setTimeout(() => { | ||
| this.openValue = false; | ||
| this.hidePopover(); | ||
| }, 100); | ||
| this.closeTimeout = setTimeout(() => this.hidePopover(), 100); | ||
| }; | ||
| handleClick = (event) => { | ||
| event.stopPropagation(); | ||
| this.openValue = !this.openValue; | ||
| this.openValue ? this.showPopover() : this.hidePopover(); | ||
| this.openValue ? this.hidePopover() : this.showPopover(); | ||
| }; | ||
| handleOutsideClick = (event) => { | ||
| if (!this.element.contains(event.target) && this.openValue) { | ||
| this.openValue = false; | ||
| this.hidePopover(); | ||
| } | ||
| if (this.element.contains(event.target)) return; | ||
| if (!this.openValue) return; | ||
| this.hidePopover(); | ||
| }; | ||
| handleKeydown = (event) => { | ||
| if (event.key !== "Escape") return; | ||
| if (!this.openValue) return; | ||
| clearTimeout(this.closeTimeout); | ||
| this.hidePopover(); | ||
| }; | ||
| // openValue is set here rather than by the callers, so a guarded early return can | ||
| // never leave the DOM claiming the popover is open while nothing is wired up. | ||
| showPopover() { | ||
| if (!this.hasTriggerTarget || !this.hasContentTarget) return; | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| this.openValue = true; | ||
| this.contentTarget.classList.remove("hidden"); | ||
| this.contentTarget.dataset.state = "open"; | ||
| document.addEventListener("keydown", this.handleKeydown); | ||
| this.updatePosition(); | ||
| } | ||
| // Same rule as disconnect(): release what is held outside the element first, so a | ||
| // missing content target cannot leave the keydown listener or autoUpdate running. | ||
| hidePopover() { | ||
| this.openValue = false; | ||
| document.removeEventListener("keydown", this.handleKeydown); | ||
| this.stopAutoUpdate(); | ||
| if (!this.hasContentTarget) return; | ||
| this.contentTarget.classList.add("hidden"); | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| this.contentTarget.dataset.state = "closed"; | ||
| } | ||
| updatePosition() { | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| this.stopAutoUpdate(); | ||
| // Hold the exact pair this run positions. A target can be detached or swapped | ||
| // while the controller stays connected, and the stale element must not be | ||
| // written to by an observer callback or an in-flight computePosition. | ||
| const trigger = this.triggerTarget; | ||
| const content = this.contentTarget; | ||
| // Deferred teardown is bound to this run's own handle, so a newer positioning | ||
| // run installed before the microtask drains is never torn down by an older one. | ||
| let stop = null; | ||
| const releaseThisRun = () => { | ||
| stop?.(); | ||
| if (this.cleanup === stop) this.cleanup = null; | ||
| }; | ||
| this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { | ||
| computePosition(this.triggerTarget, this.contentTarget, { | ||
| stop = autoUpdate(trigger, content, () => { | ||
| if (!trigger.isConnected || !content.isConnected) { | ||
| // Release the observers instead of throwing on every scroll and resize. | ||
| // Deferred because autoUpdate runs this once synchronously, before the | ||
| // handle below has been assigned. | ||
| queueMicrotask(releaseThisRun); | ||
| return; | ||
| } | ||
| computePosition(trigger, content, { | ||
| placement: this.optionsValue.placement || "bottom", | ||
| middleware: [flip(), shift(), offset(8)], | ||
| }).then(({ x, y }) => { | ||
| Object.assign(this.contentTarget.style, { | ||
| }).then(({ x, y, placement }) => { | ||
| if (!content.isConnected) return; | ||
| Object.assign(content.style, { | ||
| left: `${x}px`, | ||
| top: `${y}px`, | ||
| }); | ||
| // flip() can resolve to the opposite side of the requested placement, | ||
| // so the directional slide-in classes must follow the resolved value. | ||
| content.dataset.side = placement.split("-")[0]; | ||
| }); | ||
| }); | ||
| this.cleanup = stop; | ||
| } | ||
| stopAutoUpdate() { | ||
| if (!this.cleanup) return; | ||
| this.cleanup(); | ||
| this.cleanup = null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -19,13 +19,20 @@ export default class extends Controller { | ||
| this.closeTimeout = null; | ||
| this.cleanup = null; | ||
| this.addEventListeners(); | ||
| // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element) | ||
| // arrives already open. Re-arm the parts that live on the controller instead of | ||
| // the markup — the keydown listener and the autoUpdate positioning. | ||
| if (this.openValue) this.showPopover(); | ||
| } | ||
| // Teardown that cannot fail comes first: resolving a target throws once the | ||
| // element is gone, and Stimulus swallows that, skipping the rest of disconnect. | ||
| disconnect() { | ||
| this.removeEventListeners(); | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| clearTimeout(this.closeTimeout); | ||
| document.removeEventListener("keydown", this.handleKeydown); | ||
| document.removeEventListener("click", this.handleOutsideClick); | ||
| this.stopAutoUpdate(); | ||
| this.removeElementEventListeners(); | ||
| } | ||
| addEventListeners() { | ||
| @@ -40,68 +47,124 @@ export default class extends Controller { | ||
| } | ||
| } | ||
| removeEventListeners() { | ||
| this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.triggerTarget.removeEventListener("click", this.handleClick); | ||
| document.removeEventListener("click", this.handleOutsideClick); | ||
| // Each target is guarded on its own: losing one of them must not strand the | ||
| // listeners attached to the other. | ||
| removeElementEventListeners() { | ||
| if (this.hasTriggerTarget) { | ||
| this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| this.triggerTarget.removeEventListener("click", this.handleClick); | ||
| } | ||
| if (this.hasContentTarget) { | ||
| this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); | ||
| this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); | ||
| } | ||
| } | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| handleMouseEnter = () => { | ||
| clearTimeout(this.closeTimeout); | ||
| this.openValue = true; | ||
| this.showPopover(); | ||
| }; | ||
| handleMouseLeave = () => { | ||
| this.closeTimeout = setTimeout(() => { | ||
| this.openValue = false; | ||
| this.hidePopover(); | ||
| }, 100); | ||
| this.closeTimeout = setTimeout(() => this.hidePopover(), 100); | ||
| }; | ||
| handleClick = (event) => { | ||
| event.stopPropagation(); | ||
| this.openValue = !this.openValue; | ||
| this.openValue ? this.showPopover() : this.hidePopover(); | ||
| this.openValue ? this.hidePopover() : this.showPopover(); | ||
| }; | ||
| handleOutsideClick = (event) => { | ||
| if (!this.element.contains(event.target) && this.openValue) { | ||
| this.openValue = false; | ||
| this.hidePopover(); | ||
| } | ||
| if (this.element.contains(event.target)) return; | ||
| if (!this.openValue) return; | ||
| this.hidePopover(); | ||
| }; | ||
| handleKeydown = (event) => { | ||
| if (event.key !== "Escape") return; | ||
| if (!this.openValue) return; | ||
| clearTimeout(this.closeTimeout); | ||
| this.hidePopover(); | ||
| }; | ||
| // openValue is set here rather than by the callers, so a guarded early return can | ||
| // never leave the DOM claiming the popover is open while nothing is wired up. | ||
| showPopover() { | ||
| if (!this.hasTriggerTarget || !this.hasContentTarget) return; | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| this.openValue = true; | ||
| this.contentTarget.classList.remove("hidden"); | ||
| this.contentTarget.dataset.state = "open"; | ||
| document.addEventListener("keydown", this.handleKeydown); | ||
| this.updatePosition(); | ||
| } | ||
| // Same rule as disconnect(): release what is held outside the element first, so a | ||
| // missing content target cannot leave the keydown listener or autoUpdate running. | ||
| hidePopover() { | ||
| this.openValue = false; | ||
| document.removeEventListener("keydown", this.handleKeydown); | ||
| this.stopAutoUpdate(); | ||
| if (!this.hasContentTarget) return; | ||
| this.contentTarget.classList.add("hidden"); | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| this.contentTarget.dataset.state = "closed"; | ||
| } | ||
| updatePosition() { | ||
| if (this.cleanup) { | ||
| this.cleanup(); | ||
| } | ||
| this.stopAutoUpdate(); | ||
| // Hold the exact pair this run positions. A target can be detached or swapped | ||
| // while the controller stays connected, and the stale element must not be | ||
| // written to by an observer callback or an in-flight computePosition. | ||
| const trigger = this.triggerTarget; | ||
| const content = this.contentTarget; | ||
| // Deferred teardown is bound to this run's own handle, so a newer positioning | ||
| // run installed before the microtask drains is never torn down by an older one. | ||
| let stop = null; | ||
| const releaseThisRun = () => { | ||
| stop?.(); | ||
| if (this.cleanup === stop) this.cleanup = null; | ||
| }; | ||
| this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { | ||
| computePosition(this.triggerTarget, this.contentTarget, { | ||
| stop = autoUpdate(trigger, content, () => { | ||
| if (!trigger.isConnected || !content.isConnected) { | ||
| // Release the observers instead of throwing on every scroll and resize. | ||
| // Deferred because autoUpdate runs this once synchronously, before the | ||
| // handle below has been assigned. | ||
| queueMicrotask(releaseThisRun); | ||
| return; | ||
| } | ||
| computePosition(trigger, content, { | ||
| placement: this.optionsValue.placement || "bottom", | ||
| middleware: [flip(), shift(), offset(8)], | ||
| }).then(({ x, y }) => { | ||
| Object.assign(this.contentTarget.style, { | ||
| }).then(({ x, y, placement }) => { | ||
| if (!content.isConnected) return; | ||
| Object.assign(content.style, { | ||
| left: `${x}px`, | ||
| top: `${y}px`, | ||
| }); | ||
| // flip() can resolve to the opposite side of the requested placement, | ||
| // so the directional slide-in classes must follow the resolved value. | ||
| content.dataset.side = placement.split("-")[0]; | ||
| }); | ||
| }); | ||
| this.cleanup = stop; | ||
| } | ||
| stopAutoUpdate() { | ||
| if (!this.cleanup) return; | ||
| this.cleanup(); | ||
| this.cleanup = null; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.