mirror of
https://github.com/we-promise/sure.git
synced 2026-08-04 16:12:14 +00:00
* fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs The app refreshes pages via Turbo morph (`turbo_refreshes_with method: :morph`), and same-page account actions (disable, exclude, set-default, etc.) trigger one. Two bugs in the shared floating-ui controllers surface as a result: - The panel's `position: fixed` only ever existed as a JS-applied inline style. Idiomorph resets every menu/popover's `style` attribute to match the server-rendered markup (which has none), silently stripping `position: fixed` from every panel on the page. The next dropdown/ popover opened before floating-ui's async recompute lands briefly renders in normal flex flow, shoving its own trigger sideways and making computePosition anchor to that phantom position instead of the real button. Fix: make `position: fixed` part of the static markup so it can never be stripped. - `this.show` was a plain instance property. Because the morph preserves the Stimulus controller in place (stable-id turbo frame), `this.show` doesn't reset when idiomorph re-closes the content element, so it can desync from the DOM and swallow the next click. Fix: derive `show` from the content element's own class instead of tracking it separately. Reproduced and verified against the actual Turbo/Stimulus/floating-ui pipeline in an isolated harness before and after the fix. * test(ds): add regression coverage for menu/popover reopen-after-morph Simulates what idiomorph does to an open panel on a same-page Turbo morph — resets the content element's class back to the always-hidden server-rendered markup and strips the JS-applied inline style, without going through toggle()/close(). Verified against the pre-fix controllers that this fails without the DOM-derived `show` getter.
149 lines
4.2 KiB
JavaScript
149 lines
4.2 KiB
JavaScript
import {
|
|
autoUpdate,
|
|
computePosition,
|
|
flip,
|
|
offset,
|
|
shift,
|
|
} from "@floating-ui/dom";
|
|
import { Controller } from "@hotwired/stimulus";
|
|
|
|
/**
|
|
* Positioned panel for mixed content (forms, pickers, account menus).
|
|
* Mirrors DS--menu's positioning + open/close lifecycle but skips the
|
|
* `role="menu"` / arrow-key navigation that's specific to action lists.
|
|
* Wiring `aria-expanded` on the trigger so AT users hear "expanded" /
|
|
* "collapsed" as the panel opens / closes.
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["button", "content"];
|
|
|
|
static values = {
|
|
show: Boolean,
|
|
placement: { type: String, default: "bottom-end" },
|
|
offset: { type: Number, default: 6 },
|
|
mobileFullwidth: { type: Boolean, default: true },
|
|
};
|
|
|
|
connect() {
|
|
this.boundUpdate = this.update.bind(this);
|
|
this.addEventListeners();
|
|
this.startAutoUpdate();
|
|
}
|
|
|
|
// Derived from the content element's own class rather than tracked as
|
|
// separate state. A Turbo morph (e.g. a same-page refresh while this
|
|
// popover is mounted) re-renders the content element closed without
|
|
// going through toggle()/close(), which would otherwise leave a plain
|
|
// instance property out of sync with the DOM — swallowing the next
|
|
// click because toggle() would think it still needs to close.
|
|
get show() {
|
|
return !this.contentTarget.classList.contains("hidden");
|
|
}
|
|
|
|
disconnect() {
|
|
this.removeEventListeners();
|
|
this.stopAutoUpdate();
|
|
this.close();
|
|
}
|
|
|
|
addEventListeners() {
|
|
this.buttonTarget.addEventListener("click", this.toggle);
|
|
this.element.addEventListener("keydown", this.handleKeydown);
|
|
document.addEventListener("click", this.handleOutsideClick);
|
|
document.addEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
removeEventListeners() {
|
|
this.buttonTarget.removeEventListener("click", this.toggle);
|
|
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
document.removeEventListener("click", this.handleOutsideClick);
|
|
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
handleTurboLoad = () => {
|
|
if (!this.show) this.close();
|
|
};
|
|
|
|
handleOutsideClick = (event) => {
|
|
if (this.show && !this.element.contains(event.target)) this.close();
|
|
};
|
|
|
|
handleKeydown = (event) => {
|
|
if (event.key === "Escape") {
|
|
this.close();
|
|
this.buttonTarget.focus();
|
|
}
|
|
};
|
|
|
|
toggle = () => {
|
|
const nextShow = !this.show;
|
|
this.contentTarget.classList.toggle("hidden", !nextShow);
|
|
this.buttonTarget.setAttribute("aria-expanded", nextShow.toString());
|
|
if (nextShow) {
|
|
this.update();
|
|
this.focusFirstElement();
|
|
}
|
|
};
|
|
|
|
close() {
|
|
this.contentTarget.classList.add("hidden");
|
|
this.buttonTarget.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
focusFirstElement() {
|
|
const focusableElements =
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
const firstFocusableElement =
|
|
this.contentTarget.querySelectorAll(focusableElements)[0];
|
|
if (firstFocusableElement) {
|
|
firstFocusableElement.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
startAutoUpdate() {
|
|
if (!this._cleanup) {
|
|
this._cleanup = autoUpdate(
|
|
this.buttonTarget,
|
|
this.contentTarget,
|
|
this.boundUpdate,
|
|
);
|
|
}
|
|
}
|
|
|
|
stopAutoUpdate() {
|
|
if (this._cleanup) {
|
|
this._cleanup();
|
|
this._cleanup = null;
|
|
}
|
|
}
|
|
|
|
update() {
|
|
if (!this.buttonTarget || !this.contentTarget) return;
|
|
|
|
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
|
|
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
|
|
|
|
computePosition(this.buttonTarget, this.contentTarget, {
|
|
placement: useMobileFullwidth ? "bottom" : this.placementValue,
|
|
middleware: [offset(this.offsetValue), flip({ padding: 5 }), shift({ padding: 5 })],
|
|
strategy: "fixed",
|
|
}).then(({ x, y }) => {
|
|
if (useMobileFullwidth) {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: "0px",
|
|
width: "100vw",
|
|
top: `${y}px`,
|
|
});
|
|
} else {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
width: "",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|