openclaw/patches/@awesome.me__webawesome@3.12.0.patch
Peter Steinberger b07b7d8bd1
fix(ui): keep reopened select menus visible (#141047)
Retire superseded select animations and listeners through one accepted transition owner. Keep repeated, canceled, and interrupted show/hide calls settled, and reconcile first-render and disabled updates through the same owner.

Preserve both Web Awesome distributions and public declarations. Cover reopening, cancellation, remount, disabled teardown, and existing selection behavior with browser regressions.
2026-09-07 05:34:03 -07:00

2044 lines
87 KiB
Diff

diff --git a/dist/chunks/chunk.CWBSN34U.js b/dist/chunks/chunk.CWBSN34U.js
index 5b1df53a771e31410852af7ea483db52edbc0140..dbb7dc5db6d83acaf9261846451ce86b9eb4cab6 100644
--- a/dist/chunks/chunk.CWBSN34U.js
+++ b/dist/chunks/chunk.CWBSN34U.js
@@ -43,9 +43,6 @@ import {
import {
size_styles_default
} from "./chunk.G5ZZIGWB.js";
-import {
- waitForEvent
-} from "./chunk.F25QOBDY.js";
import {
animateWithClass
} from "./chunk.L6CIKOFQ.js";
@@ -74,6 +71,7 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this.selectionOrder = /* @__PURE__ */ new Map();
this.typeToSelectString = "";
this.slotChangePending = false;
+ this.openTransition = { open: false };
this.displayLabel = "";
this.selectedOptions = [];
this.name = "";
@@ -294,7 +292,12 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
}
disconnectedCallback() {
super.disconnectedCallback();
- this.removeOpenListeners();
+ this.openTransition.controller?.abort();
+ this.openTransition = { open: false };
+ this.popup?.popup?.classList.remove("show", "hide");
+ if (this.listbox) this.listbox.hidden = true;
+ if (this.popup) this.popup.active = false;
+ unregisterDismissible(this);
this.cachedOptions = null;
}
updateDefaultValue() {
@@ -308,24 +311,6 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this._defaultValue = this.getAttribute("value") || null;
}
}
- addOpenListeners() {
- document.addEventListener("focusin", this.handleDocumentFocusIn);
- document.addEventListener("keydown", this.handleDocumentKeyDown);
- document.addEventListener("mousedown", this.handleDocumentMouseDown);
- registerDismissible(this);
- if (this.getRootNode() !== document) {
- this.getRootNode().addEventListener("focusin", this.handleDocumentFocusIn);
- }
- }
- removeOpenListeners() {
- document.removeEventListener("focusin", this.handleDocumentFocusIn);
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("mousedown", this.handleDocumentMouseDown);
- unregisterDismissible(this);
- if (this.getRootNode() !== document) {
- this.getRootNode().removeEventListener("focusin", this.handleDocumentFocusIn);
- }
- }
handleFocus() {
this.displayInput.setSelectionRange(0, 0);
}
@@ -588,6 +573,8 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
handleDisabledChange() {
if (this.disabled && this.open) {
this.open = false;
+ // The open watcher has already run for this update.
+ this.handleOpenChange();
}
}
handleValueChange() {
@@ -598,56 +585,72 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this.updateValidity();
}
async handleOpenChange() {
- if (this.open && !this.disabled) {
- this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
- const waShowEvent = new WaShowEvent();
- this.dispatchEvent(waShowEvent);
- if (waShowEvent.defaultPrevented) {
- this.open = false;
- return;
- }
- this.addOpenListeners();
+ const open = this.open && !this.disabled;
+ if (!this.isConnected || this.openTransition.open === open) return this.openAnimation;
+ if (open) this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
+ const event = open ? new WaShowEvent() : new WaHideEvent();
+ this.dispatchEvent(event);
+ if (event.defaultPrevented && !this.disabled) {
+ this.open = this.openTransition.open;
+ return this.openAnimation;
+ }
+ if (!this.isConnected || (this.open && !this.disabled) !== open) return this.openAnimation;
+ this.openTransition.controller?.abort();
+ this.openTransition = { open, controller: new AbortController() };
+ const { signal } = this.openTransition.controller;
+ const isCurrent = () => !signal.aborted && this.isConnected && (this.open && !this.disabled) === open;
+ if (open) {
+ document.addEventListener("focusin", this.handleDocumentFocusIn, { signal });
+ document.addEventListener("keydown", this.handleDocumentKeyDown, { signal });
+ document.addEventListener("mousedown", this.handleDocumentMouseDown, { signal });
+ registerDismissible(this);
+ const root = this.getRootNode();
+ if (root !== document) root.addEventListener("focusin", this.handleDocumentFocusIn, { signal });
this.listbox.hidden = false;
this.popup.active = true;
requestAnimationFrame(() => {
- this.setCurrentOption(this.currentOption);
+ if (isCurrent()) this.setCurrentOption(this.currentOption);
});
- await animateWithClass(this.popup.popup, "show");
- if (this.currentOption) {
+ } else {
+ unregisterDismissible(this);
+ }
+ // Retire the old animation before joining its cleanup; only the accepted
+ // transition may publish visibility, focus, or completion after an await.
+ this.popup.popup.classList.remove("show", "hide");
+ const previousAnimation = this.openAnimation;
+ this.openAnimation = (async () => {
+ await previousAnimation;
+ if (!isCurrent()) return;
+ await animateWithClass(this.popup.popup, open ? "show" : "hide");
+ if (!isCurrent()) return;
+ if (open && this.currentOption) {
scrollIntoView(this.currentOption, this.listbox, "vertical", "auto");
}
- this.dispatchEvent(new WaAfterShowEvent());
- } else {
- const waHideEvent = new WaHideEvent();
- this.dispatchEvent(waHideEvent);
- if (waHideEvent.defaultPrevented) {
- this.open = false;
- return;
+ if (!open) {
+ this.listbox.hidden = true;
+ this.popup.active = false;
}
- this.removeOpenListeners();
- await animateWithClass(this.popup.popup, "hide");
- this.listbox.hidden = true;
- this.popup.active = false;
- this.dispatchEvent(new WaAfterHideEvent());
- }
+ this.dispatchEvent(open ? new WaAfterShowEvent() : new WaAfterHideEvent());
+ })();
+ return this.openAnimation;
}
/** Shows the listbox. */
async show() {
- if (this.open || this.disabled) {
+ if (this.disabled) {
this.open = false;
- return void 0;
+ return;
}
+ if (!this.isConnected) return;
this.open = true;
- return waitForEvent(this, "wa-after-show");
+ await this.updateComplete;
+ return this.handleOpenChange();
}
/** Hides the listbox. */
async hide() {
- if (!this.open || this.disabled) {
- this.open = false;
- return void 0;
- }
this.open = false;
- return waitForEvent(this, "wa-after-hide");
+ if (!this.isConnected) return;
+ await this.updateComplete;
+ return this.openAnimation;
}
/** Sets focus on the control. */
focus(options) {
diff --git a/dist/chunks/chunk.JZGJWX35.js b/dist/chunks/chunk.JZGJWX35.js
index 593bac0b7708fc0e908f9f8de650e45f8b3610ec..66e09365025b0e593e38d8410c8019d660081e16 100644
--- a/dist/chunks/chunk.JZGJWX35.js
+++ b/dist/chunks/chunk.JZGJWX35.js
@@ -29,6 +29,7 @@ var WaDropdownItem = class extends WebAwesomeElement {
constructor() {
super(...arguments);
this.hasSlotController = new HasSlotController(this, "[default]", "start", "end");
+ this.submenuGeneration = 0;
this.active = false;
this.variant = "default";
this.size = "m";
@@ -40,7 +41,10 @@ var WaDropdownItem = class extends WebAwesomeElement {
this.submenuOpen = false;
this.hasSubmenu = false;
this.handleSlotChange = () => {
- this.hasSubmenu = this.hasSlotController.test("submenu");
+ const hasSubmenu = this.hasSlotController.test("submenu");
+ // Retire the branch before losing the old submenu state and popup.
+ if (!hasSubmenu) void this.closeSubmenu();
+ this.hasSubmenu = hasSubmenu;
this.updateHasSubmenuState();
if (this.hasSubmenu) {
this.setAttribute("aria-haspopup", "menu");
@@ -67,7 +71,6 @@ var WaDropdownItem = class extends WebAwesomeElement {
/** Handles pointer enter to open the submenu on hover */
this.handlePointerEnter = (event) => {
if (event.pointerType === "mouse" && this.hasSubmenu && !this.disabled) {
- this.notifyParentOfOpening();
this.submenuOpen = true;
}
};
@@ -84,7 +87,15 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
disconnectedCallback() {
super.disconnectedCallback();
- this.closeSubmenu();
+ this.submenuGeneration++;
+ void this.closeSubmenu();
+ const submenu = this.submenuElement;
+ if (submenu) {
+ submenu.classList.remove("show", "hide");
+ submenu.hidden = true;
+ submenu.removeAttribute("data-visible");
+ submenu.hidePopover?.();
+ }
this.removeEventListener?.("click", this.handleHostClick);
this.removeEventListener?.("pointerenter", this.handlePointerEnter);
this.shadowRoot?.removeEventListener?.("click", this.handleClick, { capture: true });
@@ -127,11 +138,8 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
if (changedProperties.has("submenuOpen")) {
this.customStates.set("submenu-open", this.submenuOpen);
- if (this.submenuOpen) {
- this.openSubmenu();
- } else {
- this.closeSubmenu();
- }
+ const previousAnimation = this.submenuAnimation;
+ this.submenuAnimation = this.updateSubmenuVisibility(previousAnimation);
}
}
/** Update the has-submenu custom state */
@@ -140,22 +148,51 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
/** Opens the submenu. */
async openSubmenu() {
- const submenu = this.submenuElement;
- if (!this.hasSubmenu || !submenu || !this.isConnected) return;
- this.notifyParentOfOpening();
- submenu.showPopover?.();
- submenu.hidden = false;
- submenu.setAttribute("data-visible", "");
+ if (!this.hasSubmenu || !this.submenuElement || !this.isConnected) return;
this.submenuOpen = true;
- this.setAttribute("aria-expanded", "true");
- await animateWithClass(submenu, "show");
- setTimeout(() => {
+ await this.updateComplete;
+ return this.submenuAnimation;
+ }
+ /** Reconciles property and public-method requests through one submenu lifecycle. */
+ async updateSubmenuVisibility(previousAnimation) {
+ const submenu = this.submenuElement;
+ if (!this.submenuOpen) this.dispatchEvent(new Event("submenu-closing"));
+ if (!this.hasSubmenu || !submenu || !this.isConnected) {
+ await previousAnimation;
+ return;
+ }
+ const open = this.submenuOpen;
+ const generation = ++this.submenuGeneration;
+ const isCurrent = () => generation === this.submenuGeneration && this.isConnected && this.submenuOpen === open;
+ // Retire the previous class before joining its cleanup. A stale close must never
+ // hide a reopened native popover, and repeated public requests share this promise.
+ submenu.classList.remove("show", "hide");
+ if (open) {
+ this.notifyParentOfOpening();
+ if (!isCurrent()) return;
+ submenu.showPopover?.();
+ submenu.hidden = false;
+ submenu.setAttribute("data-visible", "");
+ }
+ // Opening observers initialize embedded controls before the expanded state changes.
+ this.setAttribute("aria-expanded", String(open));
+ await previousAnimation;
+ await this.updateComplete;
+ if (!isCurrent()) return;
+ // Focus once when opening becomes usable, not again when the animation finishes:
+ // the operator may already have moved focus or returned to the parent by then.
+ if (open) {
const items = this.getSubmenuItems();
- if (items.length > 0) {
- items.forEach((item, index) => item.active = index === 0);
- items[0].focus({ preventScroll: true });
- }
- }, 0);
+ items.forEach((item, index) => item.active = index === 0);
+ items[0]?.focus({ preventScroll: true });
+ }
+ await animateWithClass(submenu, open ? "show" : "hide");
+ if (!isCurrent()) return;
+ if (!open) {
+ submenu.hidden = true;
+ submenu.removeAttribute("data-visible");
+ submenu.hidePopover?.();
+ }
}
/** Notifies the parent dropdown that this item is opening its submenu */
notifyParentOfOpening() {
@@ -165,30 +202,15 @@ var WaDropdownItem = class extends WebAwesomeElement {
detail: { item: this }
});
this.dispatchEvent(event);
- const parent = this.parentElement;
- if (parent) {
- const siblings = [...parent.children].filter(
- (el) => el !== this && el.localName === "wa-dropdown-item" && el.getAttribute("slot") === this.getAttribute("slot") && el.submenuOpen
- );
- siblings.forEach((sibling) => {
- sibling.submenuOpen = false;
- });
- }
}
/** Closes the submenu. */
async closeSubmenu() {
- const submenu = this.submenuElement;
- if (!this.hasSubmenu || !submenu) return;
+ // A never-connected item's first update cannot settle; closing it is a public no-op.
+ if (!this.hasSubmenu || !this.submenuElement) return;
this.submenuOpen = false;
- this.setAttribute("aria-expanded", "false");
- if (!submenu.hidden) {
- await animateWithClass(submenu, "hide");
- if (submenu?.isConnected) {
- submenu.hidden = true;
- submenu.removeAttribute("data-visible");
- submenu.hidePopover?.();
- }
- }
+ this.dispatchEvent(new Event("submenu-closing"));
+ await this.updateComplete;
+ return this.submenuAnimation;
}
/** Determines whether the item navigates when selected. Items with submenus never navigate. */
isLink() {
@@ -218,8 +240,9 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
/** Gets all dropdown items in the submenu. */
getSubmenuItems() {
- return [...this.children].filter(
- (el) => el.localName === "wa-dropdown-item" && el.getAttribute("slot") === "submenu" && !el.hasAttribute("disabled")
+ const slot = this.submenuElement?.querySelector('slot[name="submenu"]');
+ return (slot?.assignedElements({ flatten: true }) ?? []).filter(
+ (el) => el.localName === "wa-dropdown-item" && !el.disabled
);
}
render() {
@@ -278,7 +301,7 @@ var WaDropdownItem = class extends WebAwesomeElement {
>
<slot name="submenu"></slot>
</div>
- ` : ""}
+ ` : html`<slot name="submenu" hidden></slot>`}
`;
}
};
diff --git a/dist/chunks/chunk.KZJTFPDQ.js b/dist/chunks/chunk.KZJTFPDQ.js
index 11de1ac9d650dc1e2db6922f8fb6cd217bc13a4d..7c076a5462bf77d2e3dc1f73335eaddbbb6054b9 100644
--- a/dist/chunks/chunk.KZJTFPDQ.js
+++ b/dist/chunks/chunk.KZJTFPDQ.js
@@ -58,17 +58,16 @@ var openDropdowns = /* @__PURE__ */ new Set();
var WaDropdown = class extends WebAwesomeElement {
constructor() {
super(...arguments);
- this.submenuCleanups = /* @__PURE__ */ new Map();
this.localize = new LocalizeController(this);
this.userTypedQuery = "";
- this.openSubmenuStack = [];
+ this.activeSubmenus = [];
this.open = false;
this.size = "m";
this.placement = "bottom-start";
this.distance = 0;
this.skidding = 0;
/** Handles key down events when the menu is open */
- this.handleDocumentKeyDown = async (event) => {
+ this.handleDocumentKeyDown = (event) => {
const isRtl = this.localize.dir() === "rtl";
if (event.key === "Escape" && this.open && isTopDismissible(this)) {
const trigger = this.getTrigger();
@@ -78,22 +77,11 @@ var WaDropdown = class extends WebAwesomeElement {
trigger?.focus({ preventScroll: true });
return;
}
- const activeElement = [...activeElements()].find((el) => el.localName === "wa-dropdown-item");
- const isFocusedOnItem = activeElement?.localName === "wa-dropdown-item";
+ const focusedItem = [...activeElements()].find((el) => el.localName === "wa-dropdown-item");
const currentSubmenuItem = this.getCurrentSubmenuItem();
- const isInSubmenu = !!currentSubmenuItem;
- let items;
- let activeItem;
- let activeItemIndex;
- if (isInSubmenu) {
- items = this.getSubmenuItems(currentSubmenuItem);
- activeItem = items.find((item) => item.active || item === activeElement);
- activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
- } else {
- items = this.getItems();
- activeItem = items.find((item) => item.active || item === activeElement);
- activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
- }
+ const items = currentSubmenuItem ? this.getSubmenuItems(currentSubmenuItem) : this.getItems();
+ const activeItem = items.find((item) => item === focusedItem) ?? items.find((item) => item.active);
+ const activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
let itemToSelect;
if (event.key === "ArrowUp") {
event.preventDefault();
@@ -113,39 +101,23 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect = items[0];
}
}
- if (event.key === (isRtl ? "ArrowLeft" : "ArrowRight") && isFocusedOnItem && activeItem) {
+ if (event.key === (isRtl ? "ArrowLeft" : "ArrowRight") && focusedItem && activeItem) {
if (activeItem.hasSubmenu) {
event.preventDefault();
event.stopPropagation();
activeItem.submenuOpen = true;
- this.addToSubmenuStack(activeItem);
- setTimeout(() => {
- const submenuItems = this.getSubmenuItems(activeItem);
- if (submenuItems.length > 0) {
- submenuItems.forEach((item, index) => item.active = index === 0);
- submenuItems[0].focus({ preventScroll: true });
- }
- }, 0);
return;
}
}
- if (event.key === (isRtl ? "ArrowRight" : "ArrowLeft") && isInSubmenu) {
+ if (event.key === (isRtl ? "ArrowRight" : "ArrowLeft") && currentSubmenuItem) {
event.preventDefault();
event.stopPropagation();
- const removedItem = this.removeFromSubmenuStack();
- if (removedItem) {
- removedItem.submenuOpen = false;
- setTimeout(() => {
- removedItem.focus({ preventScroll: true });
- removedItem.active = true;
- const parentItems = removedItem.slot === "submenu" ? this.getSubmenuItems(removedItem.parentElement) : this.getItems();
- parentItems.forEach((item) => {
- if (item !== removedItem) {
- item.active = false;
- }
- });
- }, 0);
- }
+ // Closing retires ownership synchronously; only this keypress restores focus.
+ void currentSubmenuItem.closeSubmenu();
+ currentSubmenuItem.focus({ preventScroll: true });
+ const parent = this.getCurrentSubmenuItem();
+ const parentItems = parent ? this.getSubmenuItems(parent, true) : this.getItems(true);
+ parentItems.forEach((item) => item.active = item === currentSubmenuItem);
return;
}
if (event.key === "Home" || event.key === "End") {
@@ -154,7 +126,7 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect = event.key === "Home" ? items[0] : items[items.length - 1];
}
if (event.key === "Tab") {
- await this.hideMenu();
+ this.open = false;
}
if (event.key.length === 1 && !(event.metaKey || event.ctrlKey || event.altKey) && !(event.key === " " && this.userTypedQuery === "")) {
clearTimeout(this.userTypedTimeout);
@@ -180,19 +152,11 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect.scrollIntoView({ block: "nearest" });
return;
}
- if ((event.key === "Enter" || event.key === " " && this.userTypedQuery === "") && isFocusedOnItem && activeItem) {
+ if ((event.key === "Enter" || event.key === " " && this.userTypedQuery === "") && focusedItem && activeItem) {
event.preventDefault();
event.stopPropagation();
if (activeItem.hasSubmenu) {
activeItem.submenuOpen = true;
- this.addToSubmenuStack(activeItem);
- setTimeout(() => {
- const submenuItems = this.getSubmenuItems(activeItem);
- if (submenuItems.length > 0) {
- submenuItems.forEach((item, index) => item.active = index === 0);
- submenuItems[0].focus({ preventScroll: true });
- }
- }, 0);
} else {
this.makeSelection(activeItem, event);
}
@@ -213,7 +177,8 @@ var WaDropdown = class extends WebAwesomeElement {
};
/** Handle global mouse movement for safe triangle logic */
this.handleGlobalMouseMove = (event) => {
- const currentSubmenuItem = this.getCurrentSubmenuItem();
+ const branch = this.activeSubmenus.at(-1);
+ const currentSubmenuItem = branch?.item;
if (!currentSubmenuItem?.submenuOpen || !currentSubmenuItem.submenuElement) return;
const submenuRect = currentSubmenuItem.submenuElement.getBoundingClientRect();
const isRtl = this.localize.dir() === "rtl";
@@ -231,7 +196,7 @@ var WaDropdown = class extends WebAwesomeElement {
);
if (!isOverItem && !isOverSubmenu) {
setTimeout(() => {
- if (!submenuItemHovered && !submenuElementHovered) {
+ if (this.activeSubmenus.includes(branch) && !currentSubmenuItem.matches(":hover") && !currentSubmenuItem.submenuElement.matches(":hover")) {
currentSubmenuItem.submenuOpen = false;
}
}, 100);
@@ -241,15 +206,20 @@ var WaDropdown = class extends WebAwesomeElement {
handleSizeChange() {
warnDeprecatedSize(this.localName, this.size);
}
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.hasUpdated && this.open) {
+ void this.updateMenuVisibility(true);
+ }
+ }
disconnectedCallback() {
super.disconnectedCallback();
+ this.menuTransition?.abort();
+ this.menu?.classList.remove("show", "hide");
+ if (this.popup) this.popup.active = false;
+ openDropdowns.delete(this);
clearInterval(this.userTypedTimeout);
this.closeAllSubmenus();
- this.submenuCleanups.forEach((cleanup) => cleanup());
- this.submenuCleanups.clear();
- document.removeEventListener("mousemove", this.handleGlobalMouseMove);
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
unregisterDismissible(this);
}
firstUpdated(changedProperties) {
@@ -266,12 +236,7 @@ var WaDropdown = class extends WebAwesomeElement {
return;
}
this.customStates.set("open", this.open);
- if (this.open) {
- await this.showMenu();
- } else {
- this.closeAllSubmenus();
- await this.hideMenu();
- }
+ await this.updateMenuVisibility(this.open);
}
if (changedProperties.has("size")) {
this.syncItemSizes();
@@ -300,104 +265,73 @@ var WaDropdown = class extends WebAwesomeElement {
);
items.forEach((item) => item.size = this.size);
}
- /** Handles the submenu navigation stack */
- addToSubmenuStack(item) {
- const index = this.openSubmenuStack.indexOf(item);
- if (index !== -1) {
- this.openSubmenuStack = this.openSubmenuStack.slice(0, index + 1);
- } else {
- this.openSubmenuStack.push(item);
- }
- }
- /** Removes the last item from the submenu stack */
- removeFromSubmenuStack() {
- return this.openSubmenuStack.pop();
- }
- /** Gets the current active submenu item */
+ /** Gets the current active submenu item. */
getCurrentSubmenuItem() {
- return this.openSubmenuStack.length > 0 ? this.openSubmenuStack[this.openSubmenuStack.length - 1] : void 0;
+ return this.activeSubmenus.at(-1)?.item;
}
- /** Closes all submenus in the dropdown. */
- closeAllSubmenus() {
- const items = this.getItems(true);
- items.forEach((item) => {
- item.submenuOpen = false;
- });
- this.openSubmenuStack = [];
- }
- /** Closes sibling submenus at the same level as the specified item. */
- closeSiblingSubmenus(item) {
- const parentDropdownItem = item.closest('wa-dropdown-item:not([slot="submenu"])');
- let siblingItems;
- if (parentDropdownItem) {
- siblingItems = this.getSubmenuItems(parentDropdownItem, true);
- } else {
- siblingItems = this.getItems(true);
- }
- siblingItems.forEach((siblingItem) => {
- if (siblingItem !== item && siblingItem.submenuOpen) {
- siblingItem.submenuOpen = false;
- }
- });
- if (!this.openSubmenuStack.includes(item)) {
- this.openSubmenuStack.push(item);
+ /** Retires an ancestor branch before its hide animations can complete. */
+ closeAllSubmenus(from = 0) {
+ // Remove ownership first: item close notifications may reenter this owner.
+ for (const { item, cleanup } of this.activeSubmenus.splice(from).reverse()) {
+ cleanup();
+ void item.closeSubmenu();
}
}
/** Get the slotted trigger button, a <wa-button> or <button> element */
getTrigger() {
return this.querySelector('[slot="trigger"]');
}
- /** Shows the dropdown menu. This should only be called from within updated(). */
- async showMenu() {
- const anchor = this.getTrigger();
- if (!anchor || !this.popup || !this.menu) return;
- const showEvent = new WaShowEvent();
- this.dispatchEvent(showEvent);
- if (showEvent.defaultPrevented) {
- this.open = false;
+ /** Reconciles accepted visibility and its animation from updates or reconnection. */
+ async updateMenuVisibility(open) {
+ if (!this.isConnected || !this.popup || !this.menu || open && !this.getTrigger()) return;
+ const event = open ? new WaShowEvent() : new WaHideEvent({ source: this });
+ this.dispatchEvent(event);
+ if (event.defaultPrevented) {
+ this.open = !open;
return;
}
- if (this.popup.active) {
- return;
+ // Membership records accepted open ownership. The popup stays active while hiding,
+ // so its active flag cannot distinguish a canceled hide from a reopen.
+ if (!this.isConnected || this.open !== open || open && openDropdowns.has(this)) return;
+ // One cancellation owner releases document listeners and fences completion on
+ // replacement or disconnection; a veto above leaves that ownership untouched.
+ this.menuTransition?.abort();
+ this.menuTransition = new AbortController();
+ const { signal } = this.menuTransition;
+ const isCurrent = () => !signal.aborted && this.isConnected && this.open === open;
+ if (open) {
+ openDropdowns.forEach((dropdown) => dropdown.open = false);
+ this.popup.active = true;
+ openDropdowns.add(this);
+ registerDismissible(this);
+ document.addEventListener("keydown", this.handleDocumentKeyDown, { signal });
+ document.addEventListener("pointerdown", this.handleDocumentPointerDown, { signal });
+ document.addEventListener("mousemove", this.handleGlobalMouseMove, { signal });
+ } else {
+ this.closeAllSubmenus();
+ openDropdowns.delete(this);
+ unregisterDismissible(this);
}
- openDropdowns.forEach((dropdown) => dropdown.open = false);
- this.popup.active = true;
- this.open = true;
- openDropdowns.add(this);
- registerDismissible(this);
this.syncAriaAttributes();
- document.addEventListener("keydown", this.handleDocumentKeyDown);
- document.addEventListener("pointerdown", this.handleDocumentPointerDown);
- document.addEventListener("mousemove", this.handleGlobalMouseMove);
- this.menu.classList.remove("hide");
- await animateWithClass(this.menu, "show");
- const items = this.getItems();
- if (items.length > 0) {
- items.forEach((item, index) => item.active = index === 0);
- items[0].focus({ preventScroll: true });
+ // Join canceled animation cleanup before reusing its class. Show and hide share
+ // one CSS animation name, so overlapping helpers cannot own separate animations.
+ this.menu.classList.remove("show", "hide");
+ await this.menuAnimation;
+ if (!isCurrent()) return;
+ this.menuAnimation = animateWithClass(this.menu, open ? "show" : "hide");
+ await this.menuAnimation;
+ if (!isCurrent()) return;
+ if (open) {
+ const items = this.getItems();
+ if (items.length > 0) {
+ items.forEach((item, index) => item.active = index === 0);
+ items[0].focus({ preventScroll: true });
+ }
+ if (isCurrent()) this.dispatchEvent(new WaAfterShowEvent());
+ } else {
+ this.popup.active = false;
+ this.dispatchEvent(new WaAfterHideEvent());
}
- this.dispatchEvent(new WaAfterShowEvent());
- }
- /** Hides the dropdown menu. This should only be called from within updated(). */
- async hideMenu() {
- if (!this.popup || !this.menu) return;
- const hideEvent = new WaHideEvent({ source: this });
- this.dispatchEvent(hideEvent);
- if (hideEvent.defaultPrevented) {
- this.open = true;
- return;
- }
- this.open = false;
- openDropdowns.delete(this);
- unregisterDismissible(this);
- this.syncAriaAttributes();
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
- document.removeEventListener("mousemove", this.handleGlobalMouseMove);
- this.menu.classList.remove("show");
- await animateWithClass(this.menu, "hide");
- this.popup.active = this.open;
- this.dispatchEvent(new WaAfterHideEvent());
}
/** Handles clicks on the menu. */
handleMenuClick(event) {
@@ -405,8 +339,6 @@ var WaDropdown = class extends WebAwesomeElement {
if (!item || item.disabled) return;
if (item.hasSubmenu) {
if (!item.submenuOpen) {
- this.closeSiblingSubmenus(item);
- this.addToSubmenuStack(item);
item.submenuOpen = true;
}
event.stopPropagation();
@@ -433,60 +365,57 @@ var WaDropdown = class extends WebAwesomeElement {
handleTriggerClick() {
this.open = !this.open;
}
- /** Handles submenu opening events */
+ /** Keeps one composed ancestor branch, including forwarded submenu slots. */
handleSubmenuOpening(event) {
- const openingItem = event.detail.item;
- this.closeSiblingSubmenus(openingItem);
- this.addToSubmenuStack(openingItem);
- this.setupSubmenuPosition(openingItem);
- this.processSubmenuItems(openingItem);
+ event.stopPropagation();
+ const path = event.composedPath();
+ const branch = path.slice(0, path.indexOf(this)).filter((el) => el instanceof HTMLElement && el.localName === "wa-dropdown-item").reverse();
+ // A child requested during ancestor teardown cannot own an orphan popover.
+ if (branch.some((item) => !item.submenuOpen)) {
+ void event.detail.item.closeSubmenu();
+ return;
+ }
+ let shared = 0;
+ while (shared < branch.length && this.activeSubmenus[shared]?.item === branch[shared]) shared++;
+ // Repeated ancestor notifications must not discard its still-open descendants.
+ if (shared === branch.length) return;
+ this.closeAllSubmenus(shared);
+ for (const item of branch.slice(shared)) {
+ this.activeSubmenus.push({ item, cleanup: this.setupSubmenuPosition(item) });
+ }
}
- /** Sets up submenu positioning with autoUpdate */
+ /** Binds positioning and close notifications to this exact open lifetime. */
setupSubmenuPosition(item) {
- if (!item.submenuElement) return;
- this.cleanupSubmenuPosition(item);
+ const controller = new AbortController();
+ const { signal } = controller;
const cleanup = autoUpdate(item, item.submenuElement, () => {
- this.positionSubmenu(item);
+ this.positionSubmenu(item, () => !signal.aborted);
this.updateSafeTriangleCoordinates(item);
});
- this.submenuCleanups.set(item, cleanup);
+ // Listen on the item itself: a disconnected item's event cannot bubble here.
+ item.addEventListener("submenu-closing", () => {
+ const index = this.activeSubmenus.findIndex((entry) => entry.item === item);
+ if (index !== -1) this.closeAllSubmenus(index);
+ }, { signal });
const submenuSlot = item.submenuElement.querySelector('slot[name="submenu"]');
- if (submenuSlot) {
- submenuSlot.removeEventListener("slotchange", WaDropdown.handleSubmenuSlotChange);
- submenuSlot.addEventListener("slotchange", WaDropdown.handleSubmenuSlotChange);
- WaDropdown.handleSubmenuSlotChange({ target: submenuSlot });
- }
- }
- static handleSubmenuSlotChange(event) {
- const slot = event.target;
- if (!slot) return;
- const items = slot.assignedElements().filter((el) => el.localName === "wa-dropdown-item");
- if (items.length === 0) return;
- const hasSubmenuItems = items.some((item) => item.hasSubmenu);
- const hasCheckboxItems = items.some((item) => item.type === "checkbox");
- items.forEach((item) => {
- item.submenuAdjacent = hasSubmenuItems;
- item.checkboxAdjacent = hasCheckboxItems;
- });
+ submenuSlot?.addEventListener("slotchange", () => this.processSubmenuItems(item), { signal });
+ this.processSubmenuItems(item);
+ return () => {
+ controller.abort();
+ cleanup();
+ };
}
processSubmenuItems(item) {
- if (!item.submenuElement) return;
- const submenuItems = this.getSubmenuItems(item, true);
- const hasSubmenuItems = submenuItems.some((subItem) => subItem.hasSubmenu);
- submenuItems.forEach((subItem) => {
- subItem.submenuAdjacent = hasSubmenuItems;
+ const items = this.getSubmenuItems(item, true);
+ const hasSubmenu = items.some((child) => child.hasSubmenu);
+ const hasCheckbox = items.some((child) => child.type === "checkbox");
+ items.forEach((child) => {
+ child.submenuAdjacent = hasSubmenu;
+ child.checkboxAdjacent = hasCheckbox;
});
}
- /** Cleans up submenu positioning */
- cleanupSubmenuPosition(item) {
- const cleanup = this.submenuCleanups.get(item);
- if (cleanup) {
- cleanup();
- this.submenuCleanups.delete(item);
- }
- }
/** Positions a submenu relative to its parent item */
- positionSubmenu(item) {
+ positionSubmenu(item, isCurrent) {
if (!item.submenuElement) return;
const isRtl = this.localize.dir() === "rtl";
const placement = isRtl ? "left-start" : "right-start";
@@ -506,6 +435,7 @@ var WaDropdown = class extends WebAwesomeElement {
})
]
}).then(({ x, y, placement: placement2 }) => {
+ if (!isCurrent()) return;
item.submenuElement.setAttribute("data-placement", placement2);
Object.assign(item.submenuElement.style, {
left: `${x}px`,
diff --git a/dist/chunks/chunk.L6CIKOFQ.js b/dist/chunks/chunk.L6CIKOFQ.js
index 20d309f89017ef7d1857f3b1c88216a88354fefd..ea0b8ee34255009d9d9e86605dbdc05969ea3840 100644
--- a/dist/chunks/chunk.L6CIKOFQ.js
+++ b/dist/chunks/chunk.L6CIKOFQ.js
@@ -5,32 +5,19 @@ async function animate(el, keyframes, options) {
return el.animate(keyframes, options).finished.catch(() => {
});
}
-function animateWithClass(el, className) {
- return new Promise((resolve) => {
- const controller = new AbortController();
- const { signal } = controller;
- if (el.classList.contains(className)) {
- return;
- }
- el.classList.add(className);
- let resolved = false;
- let onEnd = () => {
- if (resolved) {
- return;
- }
- resolved = true;
- el.classList.remove(className);
- resolve();
- controller.abort();
- };
- el.addEventListener("animationend", onEnd, { once: true, signal });
- el.addEventListener("animationcancel", onEnd, { once: true, signal });
- requestAnimationFrame(() => {
- if (!resolved && el.getAnimations().length === 0) {
- onEnd();
- }
- });
- });
+async function animateWithClass(el, className) {
+ // Observe pending class removal before reusing the same animation name;
+ // otherwise an immediate show/hide can reuse the completed CSSAnimation.
+ el.getAnimations();
+ el.classList.add(className);
+ // Popup activation is reactive: let its display styles render before sampling.
+ // Native completion also settles animations canceled before animationstart, unlike DOM events.
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ const animations = el.getAnimations().filter(
+ (animation) => animation instanceof CSSAnimation && animation.effect?.getComputedTiming().endTime !== Infinity
+ );
+ await Promise.allSettled(animations.map((animation) => animation.finished));
+ el.classList.remove(className);
}
function parseDuration(duration) {
duration = duration.toString().toLowerCase();
diff --git a/dist/components/dropdown/dropdown.d.ts b/dist/components/dropdown/dropdown.d.ts
index 561ccfa258274c2c41cb728c0bfef26381c9f30c..7da1ccc5e1a55eeef5df886f612449832dc01157 100644
--- a/dist/components/dropdown/dropdown.d.ts
+++ b/dist/components/dropdown/dropdown.d.ts
@@ -29,11 +29,12 @@ import '../popup/popup.js';
*/
export default class WaDropdown extends WebAwesomeElement {
static css: import("lit").CSSResult[];
- private submenuCleanups;
private readonly localize;
private userTypedQuery;
private userTypedTimeout;
- private openSubmenuStack;
+ private activeSubmenus;
+ private menuTransition?;
+ private menuAnimation?;
defaultSlot: HTMLSlotElement;
private menu;
private popup;
@@ -51,6 +52,7 @@ export default class WaDropdown extends WebAwesomeElement {
distance: number;
/** The offset of the dropdown menu along its trigger. */
skidding: number;
+ connectedCallback(): void;
disconnectedCallback(): void;
firstUpdated(changedProperties: PropertyValues<typeof this>): void;
updated(changedProperties: PropertyValues<typeof this>): Promise<void>;
@@ -60,22 +62,14 @@ export default class WaDropdown extends WebAwesomeElement {
private getSubmenuItems;
/** Syncs item sizes with the dropdown's size property. */
private syncItemSizes;
- /** Handles the submenu navigation stack */
- private addToSubmenuStack;
- /** Removes the last item from the submenu stack */
- private removeFromSubmenuStack;
- /** Gets the current active submenu item */
+ /** Gets the current active submenu item. */
private getCurrentSubmenuItem;
- /** Closes all submenus in the dropdown. */
+ /** Retires an ancestor branch before its hide animations can complete. */
private closeAllSubmenus;
- /** Closes sibling submenus at the same level as the specified item. */
- private closeSiblingSubmenus;
/** Get the slotted trigger button, a <wa-button> or <button> element */
private getTrigger;
- /** Shows the dropdown menu. This should only be called from within updated(). */
- private showMenu;
- /** Hides the dropdown menu. This should only be called from within updated(). */
- private hideMenu;
+ /** Reconciles accepted visibility and its animation from updates or reconnection. */
+ private updateMenuVisibility;
/** Handles key down events when the menu is open */
private handleDocumentKeyDown;
/** Handles pointer down events when the dropdown is open. */
@@ -90,10 +84,7 @@ export default class WaDropdown extends WebAwesomeElement {
private handleSubmenuOpening;
/** Sets up submenu positioning with autoUpdate */
private setupSubmenuPosition;
- private static handleSubmenuSlotChange;
private processSubmenuItems;
- /** Cleans up submenu positioning */
- private cleanupSubmenuPosition;
/** Positions a submenu relative to its parent item */
private positionSubmenu;
/** Updates the safe triangle coordinates for a submenu */
diff --git a/dist/components/dropdown-item/dropdown-item.d.ts b/dist/components/dropdown-item/dropdown-item.d.ts
index 1e458bc4c742d94cd58d688acde8a45f9c681763..477eac752d3d2c0a81ee3a59a80c09b81d972888 100644
--- a/dist/components/dropdown-item/dropdown-item.d.ts
+++ b/dist/components/dropdown-item/dropdown-item.d.ts
@@ -35,6 +35,8 @@ import '../icon/icon.js';
export default class WaDropdownItem extends WebAwesomeElement {
static css: import("lit").CSSResult;
private readonly hasSlotController;
+ private submenuGeneration;
+ private submenuAnimation?;
submenuElement: HTMLDivElement;
private linkElement;
/** @internal The controller will set this property to true when the item is active. */
@@ -91,6 +93,8 @@ export default class WaDropdownItem extends WebAwesomeElement {
private updateHasSubmenuState;
/** Opens the submenu. */
openSubmenu(): Promise<void>;
+ /** Reconciles property and public-method requests through one submenu lifecycle. */
+ private updateSubmenuVisibility;
/** Notifies the parent dropdown that this item is opening its submenu */
private notifyParentOfOpening;
/** Closes the submenu. */
diff --git a/dist/components/select/select.d.ts b/dist/components/select/select.d.ts
index 9b4c748a83e5e990fd6eeb6367b37713b56773b2..f801fce20728a10630b6e47099a1054b464fb37c 100644
--- a/dist/components/select/select.d.ts
+++ b/dist/components/select/select.d.ts
@@ -66,6 +66,8 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
static get validators(): import("../../internal/webawesome-form-associated-element.js").Validator<WebAwesomeFormAssociatedElement>[];
assumeInteractionOn: string[];
private cachedOptions;
+ private openTransition;
+ private openAnimation;
private readonly hasSlotController;
private readonly localize;
private selectionOrder;
@@ -154,8 +156,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
connectedCallback(): void;
disconnectedCallback(): void;
private updateDefaultValue;
- private addOpenListeners;
- private removeOpenListeners;
private handleFocus;
private handleDocumentFocusIn;
private handleDocumentKeyDown;
diff --git a/dist/custom-elements.json b/dist/custom-elements.json
index 4128d582ee76895db1e7a8b55349680d84bfa40f..51fa14f3ad1234ce8fc7991fd83b002b30238d92 100644
--- a/dist/custom-elements.json
+++ b/dist/custom-elements.json
@@ -27842,13 +27842,13 @@
"privacy": "private"
},
{
- "kind": "method",
- "name": "addOpenListeners",
+ "kind": "field",
+ "name": "openTransition",
"privacy": "private"
},
{
- "kind": "method",
- "name": "removeOpenListeners",
+ "kind": "field",
+ "name": "openAnimation",
"privacy": "private"
},
{
diff --git a/dist/internal/animate.d.ts b/dist/internal/animate.d.ts
index bedb1923179f2a73be2eaf24e8c1a3c35718683d..04dc1c15097ad58de31a8670e2c870d3a475b96f 100644
--- a/dist/internal/animate.d.ts
+++ b/dist/internal/animate.d.ts
@@ -1,9 +1,6 @@
/** Same as `el.animate()`, except returns a promise that doesn't throw an error when the animation is canceled. */
export declare function animate(el: Element, keyframes: Keyframe[], options?: KeyframeAnimationOptions): Promise<void | Animation>;
-/**
- * Applies a class to the specified element to animate it. The class is removed after the animation finishes and then
- * the promise resolves. If a timeout is provided, the class will be removed and the animation will
- */
+/** Applies an animation class and removes it after its finite CSS animations finish or are canceled. */
export declare function animateWithClass(el: Element, className: string): Promise<void>;
/** Parses a CSS duration and returns the number of milliseconds. */
export declare function parseDuration(duration: number | string): number;
diff --git a/dist-cdn/chunks/chunk.63RYV7SI.js b/dist-cdn/chunks/chunk.63RYV7SI.js
index 886b0e05a9d51bbe6636d1e394d49ef4edfb84fc..93d65f3128c2a4acb30c51ac3899b8d809f7ab33 100644
--- a/dist-cdn/chunks/chunk.63RYV7SI.js
+++ b/dist-cdn/chunks/chunk.63RYV7SI.js
@@ -68,17 +68,16 @@ var openDropdowns = /* @__PURE__ */ new Set();
var WaDropdown = class extends WebAwesomeElement {
constructor() {
super(...arguments);
- this.submenuCleanups = /* @__PURE__ */ new Map();
this.localize = new LocalizeController(this);
this.userTypedQuery = "";
- this.openSubmenuStack = [];
+ this.activeSubmenus = [];
this.open = false;
this.size = "m";
this.placement = "bottom-start";
this.distance = 0;
this.skidding = 0;
/** Handles key down events when the menu is open */
- this.handleDocumentKeyDown = async (event) => {
+ this.handleDocumentKeyDown = (event) => {
const isRtl = this.localize.dir() === "rtl";
if (event.key === "Escape" && this.open && isTopDismissible(this)) {
const trigger = this.getTrigger();
@@ -88,22 +87,11 @@ var WaDropdown = class extends WebAwesomeElement {
trigger?.focus({ preventScroll: true });
return;
}
- const activeElement = [...activeElements()].find((el) => el.localName === "wa-dropdown-item");
- const isFocusedOnItem = activeElement?.localName === "wa-dropdown-item";
+ const focusedItem = [...activeElements()].find((el) => el.localName === "wa-dropdown-item");
const currentSubmenuItem = this.getCurrentSubmenuItem();
- const isInSubmenu = !!currentSubmenuItem;
- let items;
- let activeItem;
- let activeItemIndex;
- if (isInSubmenu) {
- items = this.getSubmenuItems(currentSubmenuItem);
- activeItem = items.find((item) => item.active || item === activeElement);
- activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
- } else {
- items = this.getItems();
- activeItem = items.find((item) => item.active || item === activeElement);
- activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
- }
+ const items = currentSubmenuItem ? this.getSubmenuItems(currentSubmenuItem) : this.getItems();
+ const activeItem = items.find((item) => item === focusedItem) ?? items.find((item) => item.active);
+ const activeItemIndex = activeItem ? items.indexOf(activeItem) : -1;
let itemToSelect;
if (event.key === "ArrowUp") {
event.preventDefault();
@@ -123,39 +111,23 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect = items[0];
}
}
- if (event.key === (isRtl ? "ArrowLeft" : "ArrowRight") && isFocusedOnItem && activeItem) {
+ if (event.key === (isRtl ? "ArrowLeft" : "ArrowRight") && focusedItem && activeItem) {
if (activeItem.hasSubmenu) {
event.preventDefault();
event.stopPropagation();
activeItem.submenuOpen = true;
- this.addToSubmenuStack(activeItem);
- setTimeout(() => {
- const submenuItems = this.getSubmenuItems(activeItem);
- if (submenuItems.length > 0) {
- submenuItems.forEach((item, index) => item.active = index === 0);
- submenuItems[0].focus({ preventScroll: true });
- }
- }, 0);
return;
}
}
- if (event.key === (isRtl ? "ArrowRight" : "ArrowLeft") && isInSubmenu) {
+ if (event.key === (isRtl ? "ArrowRight" : "ArrowLeft") && currentSubmenuItem) {
event.preventDefault();
event.stopPropagation();
- const removedItem = this.removeFromSubmenuStack();
- if (removedItem) {
- removedItem.submenuOpen = false;
- setTimeout(() => {
- removedItem.focus({ preventScroll: true });
- removedItem.active = true;
- const parentItems = removedItem.slot === "submenu" ? this.getSubmenuItems(removedItem.parentElement) : this.getItems();
- parentItems.forEach((item) => {
- if (item !== removedItem) {
- item.active = false;
- }
- });
- }, 0);
- }
+ // Closing retires ownership synchronously; only this keypress restores focus.
+ void currentSubmenuItem.closeSubmenu();
+ currentSubmenuItem.focus({ preventScroll: true });
+ const parent = this.getCurrentSubmenuItem();
+ const parentItems = parent ? this.getSubmenuItems(parent, true) : this.getItems(true);
+ parentItems.forEach((item) => item.active = item === currentSubmenuItem);
return;
}
if (event.key === "Home" || event.key === "End") {
@@ -164,7 +136,7 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect = event.key === "Home" ? items[0] : items[items.length - 1];
}
if (event.key === "Tab") {
- await this.hideMenu();
+ this.open = false;
}
if (event.key.length === 1 && !(event.metaKey || event.ctrlKey || event.altKey) && !(event.key === " " && this.userTypedQuery === "")) {
clearTimeout(this.userTypedTimeout);
@@ -190,19 +162,11 @@ var WaDropdown = class extends WebAwesomeElement {
itemToSelect.scrollIntoView({ block: "nearest" });
return;
}
- if ((event.key === "Enter" || event.key === " " && this.userTypedQuery === "") && isFocusedOnItem && activeItem) {
+ if ((event.key === "Enter" || event.key === " " && this.userTypedQuery === "") && focusedItem && activeItem) {
event.preventDefault();
event.stopPropagation();
if (activeItem.hasSubmenu) {
activeItem.submenuOpen = true;
- this.addToSubmenuStack(activeItem);
- setTimeout(() => {
- const submenuItems = this.getSubmenuItems(activeItem);
- if (submenuItems.length > 0) {
- submenuItems.forEach((item, index) => item.active = index === 0);
- submenuItems[0].focus({ preventScroll: true });
- }
- }, 0);
} else {
this.makeSelection(activeItem, event);
}
@@ -223,7 +187,8 @@ var WaDropdown = class extends WebAwesomeElement {
};
/** Handle global mouse movement for safe triangle logic */
this.handleGlobalMouseMove = (event) => {
- const currentSubmenuItem = this.getCurrentSubmenuItem();
+ const branch = this.activeSubmenus.at(-1);
+ const currentSubmenuItem = branch?.item;
if (!currentSubmenuItem?.submenuOpen || !currentSubmenuItem.submenuElement) return;
const submenuRect = currentSubmenuItem.submenuElement.getBoundingClientRect();
const isRtl = this.localize.dir() === "rtl";
@@ -241,7 +206,7 @@ var WaDropdown = class extends WebAwesomeElement {
);
if (!isOverItem && !isOverSubmenu) {
setTimeout(() => {
- if (!submenuItemHovered && !submenuElementHovered) {
+ if (this.activeSubmenus.includes(branch) && !currentSubmenuItem.matches(":hover") && !currentSubmenuItem.submenuElement.matches(":hover")) {
currentSubmenuItem.submenuOpen = false;
}
}, 100);
@@ -251,15 +216,20 @@ var WaDropdown = class extends WebAwesomeElement {
handleSizeChange() {
warnDeprecatedSize(this.localName, this.size);
}
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.hasUpdated && this.open) {
+ void this.updateMenuVisibility(true);
+ }
+ }
disconnectedCallback() {
super.disconnectedCallback();
+ this.menuTransition?.abort();
+ this.menu?.classList.remove("show", "hide");
+ if (this.popup) this.popup.active = false;
+ openDropdowns.delete(this);
clearInterval(this.userTypedTimeout);
this.closeAllSubmenus();
- this.submenuCleanups.forEach((cleanup) => cleanup());
- this.submenuCleanups.clear();
- document.removeEventListener("mousemove", this.handleGlobalMouseMove);
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
unregisterDismissible(this);
}
firstUpdated(changedProperties) {
@@ -276,12 +246,7 @@ var WaDropdown = class extends WebAwesomeElement {
return;
}
this.customStates.set("open", this.open);
- if (this.open) {
- await this.showMenu();
- } else {
- this.closeAllSubmenus();
- await this.hideMenu();
- }
+ await this.updateMenuVisibility(this.open);
}
if (changedProperties.has("size")) {
this.syncItemSizes();
@@ -310,104 +275,73 @@ var WaDropdown = class extends WebAwesomeElement {
);
items.forEach((item) => item.size = this.size);
}
- /** Handles the submenu navigation stack */
- addToSubmenuStack(item) {
- const index = this.openSubmenuStack.indexOf(item);
- if (index !== -1) {
- this.openSubmenuStack = this.openSubmenuStack.slice(0, index + 1);
- } else {
- this.openSubmenuStack.push(item);
- }
- }
- /** Removes the last item from the submenu stack */
- removeFromSubmenuStack() {
- return this.openSubmenuStack.pop();
- }
- /** Gets the current active submenu item */
+ /** Gets the current active submenu item. */
getCurrentSubmenuItem() {
- return this.openSubmenuStack.length > 0 ? this.openSubmenuStack[this.openSubmenuStack.length - 1] : void 0;
+ return this.activeSubmenus.at(-1)?.item;
}
- /** Closes all submenus in the dropdown. */
- closeAllSubmenus() {
- const items = this.getItems(true);
- items.forEach((item) => {
- item.submenuOpen = false;
- });
- this.openSubmenuStack = [];
- }
- /** Closes sibling submenus at the same level as the specified item. */
- closeSiblingSubmenus(item) {
- const parentDropdownItem = item.closest('wa-dropdown-item:not([slot="submenu"])');
- let siblingItems;
- if (parentDropdownItem) {
- siblingItems = this.getSubmenuItems(parentDropdownItem, true);
- } else {
- siblingItems = this.getItems(true);
- }
- siblingItems.forEach((siblingItem) => {
- if (siblingItem !== item && siblingItem.submenuOpen) {
- siblingItem.submenuOpen = false;
- }
- });
- if (!this.openSubmenuStack.includes(item)) {
- this.openSubmenuStack.push(item);
+ /** Retires an ancestor branch before its hide animations can complete. */
+ closeAllSubmenus(from = 0) {
+ // Remove ownership first: item close notifications may reenter this owner.
+ for (const { item, cleanup } of this.activeSubmenus.splice(from).reverse()) {
+ cleanup();
+ void item.closeSubmenu();
}
}
/** Get the slotted trigger button, a <wa-button> or <button> element */
getTrigger() {
return this.querySelector('[slot="trigger"]');
}
- /** Shows the dropdown menu. This should only be called from within updated(). */
- async showMenu() {
- const anchor = this.getTrigger();
- if (!anchor || !this.popup || !this.menu) return;
- const showEvent = new WaShowEvent();
- this.dispatchEvent(showEvent);
- if (showEvent.defaultPrevented) {
- this.open = false;
+ /** Reconciles accepted visibility and its animation from updates or reconnection. */
+ async updateMenuVisibility(open) {
+ if (!this.isConnected || !this.popup || !this.menu || open && !this.getTrigger()) return;
+ const event = open ? new WaShowEvent() : new WaHideEvent({ source: this });
+ this.dispatchEvent(event);
+ if (event.defaultPrevented) {
+ this.open = !open;
return;
}
- if (this.popup.active) {
- return;
+ // Membership records accepted open ownership. The popup stays active while hiding,
+ // so its active flag cannot distinguish a canceled hide from a reopen.
+ if (!this.isConnected || this.open !== open || open && openDropdowns.has(this)) return;
+ // One cancellation owner releases document listeners and fences completion on
+ // replacement or disconnection; a veto above leaves that ownership untouched.
+ this.menuTransition?.abort();
+ this.menuTransition = new AbortController();
+ const { signal } = this.menuTransition;
+ const isCurrent = () => !signal.aborted && this.isConnected && this.open === open;
+ if (open) {
+ openDropdowns.forEach((dropdown) => dropdown.open = false);
+ this.popup.active = true;
+ openDropdowns.add(this);
+ registerDismissible(this);
+ document.addEventListener("keydown", this.handleDocumentKeyDown, { signal });
+ document.addEventListener("pointerdown", this.handleDocumentPointerDown, { signal });
+ document.addEventListener("mousemove", this.handleGlobalMouseMove, { signal });
+ } else {
+ this.closeAllSubmenus();
+ openDropdowns.delete(this);
+ unregisterDismissible(this);
}
- openDropdowns.forEach((dropdown) => dropdown.open = false);
- this.popup.active = true;
- this.open = true;
- openDropdowns.add(this);
- registerDismissible(this);
this.syncAriaAttributes();
- document.addEventListener("keydown", this.handleDocumentKeyDown);
- document.addEventListener("pointerdown", this.handleDocumentPointerDown);
- document.addEventListener("mousemove", this.handleGlobalMouseMove);
- this.menu.classList.remove("hide");
- await animateWithClass(this.menu, "show");
- const items = this.getItems();
- if (items.length > 0) {
- items.forEach((item, index) => item.active = index === 0);
- items[0].focus({ preventScroll: true });
+ // Join canceled animation cleanup before reusing its class. Show and hide share
+ // one CSS animation name, so overlapping helpers cannot own separate animations.
+ this.menu.classList.remove("show", "hide");
+ await this.menuAnimation;
+ if (!isCurrent()) return;
+ this.menuAnimation = animateWithClass(this.menu, open ? "show" : "hide");
+ await this.menuAnimation;
+ if (!isCurrent()) return;
+ if (open) {
+ const items = this.getItems();
+ if (items.length > 0) {
+ items.forEach((item, index) => item.active = index === 0);
+ items[0].focus({ preventScroll: true });
+ }
+ if (isCurrent()) this.dispatchEvent(new WaAfterShowEvent());
+ } else {
+ this.popup.active = false;
+ this.dispatchEvent(new WaAfterHideEvent());
}
- this.dispatchEvent(new WaAfterShowEvent());
- }
- /** Hides the dropdown menu. This should only be called from within updated(). */
- async hideMenu() {
- if (!this.popup || !this.menu) return;
- const hideEvent = new WaHideEvent({ source: this });
- this.dispatchEvent(hideEvent);
- if (hideEvent.defaultPrevented) {
- this.open = true;
- return;
- }
- this.open = false;
- openDropdowns.delete(this);
- unregisterDismissible(this);
- this.syncAriaAttributes();
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
- document.removeEventListener("mousemove", this.handleGlobalMouseMove);
- this.menu.classList.remove("show");
- await animateWithClass(this.menu, "hide");
- this.popup.active = this.open;
- this.dispatchEvent(new WaAfterHideEvent());
}
/** Handles clicks on the menu. */
handleMenuClick(event) {
@@ -415,8 +349,6 @@ var WaDropdown = class extends WebAwesomeElement {
if (!item || item.disabled) return;
if (item.hasSubmenu) {
if (!item.submenuOpen) {
- this.closeSiblingSubmenus(item);
- this.addToSubmenuStack(item);
item.submenuOpen = true;
}
event.stopPropagation();
@@ -443,60 +375,57 @@ var WaDropdown = class extends WebAwesomeElement {
handleTriggerClick() {
this.open = !this.open;
}
- /** Handles submenu opening events */
+ /** Keeps one composed ancestor branch, including forwarded submenu slots. */
handleSubmenuOpening(event) {
- const openingItem = event.detail.item;
- this.closeSiblingSubmenus(openingItem);
- this.addToSubmenuStack(openingItem);
- this.setupSubmenuPosition(openingItem);
- this.processSubmenuItems(openingItem);
+ event.stopPropagation();
+ const path = event.composedPath();
+ const branch = path.slice(0, path.indexOf(this)).filter((el) => el instanceof HTMLElement && el.localName === "wa-dropdown-item").reverse();
+ // A child requested during ancestor teardown cannot own an orphan popover.
+ if (branch.some((item) => !item.submenuOpen)) {
+ void event.detail.item.closeSubmenu();
+ return;
+ }
+ let shared = 0;
+ while (shared < branch.length && this.activeSubmenus[shared]?.item === branch[shared]) shared++;
+ // Repeated ancestor notifications must not discard its still-open descendants.
+ if (shared === branch.length) return;
+ this.closeAllSubmenus(shared);
+ for (const item of branch.slice(shared)) {
+ this.activeSubmenus.push({ item, cleanup: this.setupSubmenuPosition(item) });
+ }
}
- /** Sets up submenu positioning with autoUpdate */
+ /** Binds positioning and close notifications to this exact open lifetime. */
setupSubmenuPosition(item) {
- if (!item.submenuElement) return;
- this.cleanupSubmenuPosition(item);
+ const controller = new AbortController();
+ const { signal } = controller;
const cleanup = autoUpdate(item, item.submenuElement, () => {
- this.positionSubmenu(item);
+ this.positionSubmenu(item, () => !signal.aborted);
this.updateSafeTriangleCoordinates(item);
});
- this.submenuCleanups.set(item, cleanup);
+ // Listen on the item itself: a disconnected item's event cannot bubble here.
+ item.addEventListener("submenu-closing", () => {
+ const index = this.activeSubmenus.findIndex((entry) => entry.item === item);
+ if (index !== -1) this.closeAllSubmenus(index);
+ }, { signal });
const submenuSlot = item.submenuElement.querySelector('slot[name="submenu"]');
- if (submenuSlot) {
- submenuSlot.removeEventListener("slotchange", WaDropdown.handleSubmenuSlotChange);
- submenuSlot.addEventListener("slotchange", WaDropdown.handleSubmenuSlotChange);
- WaDropdown.handleSubmenuSlotChange({ target: submenuSlot });
- }
- }
- static handleSubmenuSlotChange(event) {
- const slot = event.target;
- if (!slot) return;
- const items = slot.assignedElements().filter((el) => el.localName === "wa-dropdown-item");
- if (items.length === 0) return;
- const hasSubmenuItems = items.some((item) => item.hasSubmenu);
- const hasCheckboxItems = items.some((item) => item.type === "checkbox");
- items.forEach((item) => {
- item.submenuAdjacent = hasSubmenuItems;
- item.checkboxAdjacent = hasCheckboxItems;
- });
+ submenuSlot?.addEventListener("slotchange", () => this.processSubmenuItems(item), { signal });
+ this.processSubmenuItems(item);
+ return () => {
+ controller.abort();
+ cleanup();
+ };
}
processSubmenuItems(item) {
- if (!item.submenuElement) return;
- const submenuItems = this.getSubmenuItems(item, true);
- const hasSubmenuItems = submenuItems.some((subItem) => subItem.hasSubmenu);
- submenuItems.forEach((subItem) => {
- subItem.submenuAdjacent = hasSubmenuItems;
+ const items = this.getSubmenuItems(item, true);
+ const hasSubmenu = items.some((child) => child.hasSubmenu);
+ const hasCheckbox = items.some((child) => child.type === "checkbox");
+ items.forEach((child) => {
+ child.submenuAdjacent = hasSubmenu;
+ child.checkboxAdjacent = hasCheckbox;
});
}
- /** Cleans up submenu positioning */
- cleanupSubmenuPosition(item) {
- const cleanup = this.submenuCleanups.get(item);
- if (cleanup) {
- cleanup();
- this.submenuCleanups.delete(item);
- }
- }
/** Positions a submenu relative to its parent item */
- positionSubmenu(item) {
+ positionSubmenu(item, isCurrent) {
if (!item.submenuElement) return;
const isRtl = this.localize.dir() === "rtl";
const placement = isRtl ? "left-start" : "right-start";
@@ -516,6 +445,7 @@ var WaDropdown = class extends WebAwesomeElement {
})
]
}).then(({ x: x2, y, placement: placement2 }) => {
+ if (!isCurrent()) return;
item.submenuElement.setAttribute("data-placement", placement2);
Object.assign(item.submenuElement.style, {
left: `${x2}px`,
diff --git a/dist-cdn/chunks/chunk.L6CIKOFQ.js b/dist-cdn/chunks/chunk.L6CIKOFQ.js
index 20d309f89017ef7d1857f3b1c88216a88354fefd..ea0b8ee34255009d9d9e86605dbdc05969ea3840 100644
--- a/dist-cdn/chunks/chunk.L6CIKOFQ.js
+++ b/dist-cdn/chunks/chunk.L6CIKOFQ.js
@@ -5,32 +5,19 @@ async function animate(el, keyframes, options) {
return el.animate(keyframes, options).finished.catch(() => {
});
}
-function animateWithClass(el, className) {
- return new Promise((resolve) => {
- const controller = new AbortController();
- const { signal } = controller;
- if (el.classList.contains(className)) {
- return;
- }
- el.classList.add(className);
- let resolved = false;
- let onEnd = () => {
- if (resolved) {
- return;
- }
- resolved = true;
- el.classList.remove(className);
- resolve();
- controller.abort();
- };
- el.addEventListener("animationend", onEnd, { once: true, signal });
- el.addEventListener("animationcancel", onEnd, { once: true, signal });
- requestAnimationFrame(() => {
- if (!resolved && el.getAnimations().length === 0) {
- onEnd();
- }
- });
- });
+async function animateWithClass(el, className) {
+ // Observe pending class removal before reusing the same animation name;
+ // otherwise an immediate show/hide can reuse the completed CSSAnimation.
+ el.getAnimations();
+ el.classList.add(className);
+ // Popup activation is reactive: let its display styles render before sampling.
+ // Native completion also settles animations canceled before animationstart, unlike DOM events.
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ const animations = el.getAnimations().filter(
+ (animation) => animation instanceof CSSAnimation && animation.effect?.getComputedTiming().endTime !== Infinity
+ );
+ await Promise.allSettled(animations.map((animation) => animation.finished));
+ el.classList.remove(className);
}
function parseDuration(duration) {
duration = duration.toString().toLowerCase();
diff --git a/dist-cdn/chunks/chunk.VVUBGO75.js b/dist-cdn/chunks/chunk.VVUBGO75.js
index 79243983dd4273713e7d1cc5d7e9a7b103cb787c..b7007d1129565982cac88ea9878d78e47072e340 100644
--- a/dist-cdn/chunks/chunk.VVUBGO75.js
+++ b/dist-cdn/chunks/chunk.VVUBGO75.js
@@ -46,9 +46,6 @@ import {
import {
size_styles_default
} from "./chunk.JB5Y2AN3.js";
-import {
- waitForEvent
-} from "./chunk.F25QOBDY.js";
import {
animateWithClass
} from "./chunk.L6CIKOFQ.js";
@@ -88,6 +85,7 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this.selectionOrder = /* @__PURE__ */ new Map();
this.typeToSelectString = "";
this.slotChangePending = false;
+ this.openTransition = { open: false };
this.displayLabel = "";
this.selectedOptions = [];
this.name = "";
@@ -308,7 +306,12 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
}
disconnectedCallback() {
super.disconnectedCallback();
- this.removeOpenListeners();
+ this.openTransition.controller?.abort();
+ this.openTransition = { open: false };
+ this.popup?.popup?.classList.remove("show", "hide");
+ if (this.listbox) this.listbox.hidden = true;
+ if (this.popup) this.popup.active = false;
+ unregisterDismissible(this);
this.cachedOptions = null;
}
updateDefaultValue() {
@@ -322,24 +325,6 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this._defaultValue = this.getAttribute("value") || null;
}
}
- addOpenListeners() {
- document.addEventListener("focusin", this.handleDocumentFocusIn);
- document.addEventListener("keydown", this.handleDocumentKeyDown);
- document.addEventListener("mousedown", this.handleDocumentMouseDown);
- registerDismissible(this);
- if (this.getRootNode() !== document) {
- this.getRootNode().addEventListener("focusin", this.handleDocumentFocusIn);
- }
- }
- removeOpenListeners() {
- document.removeEventListener("focusin", this.handleDocumentFocusIn);
- document.removeEventListener("keydown", this.handleDocumentKeyDown);
- document.removeEventListener("mousedown", this.handleDocumentMouseDown);
- unregisterDismissible(this);
- if (this.getRootNode() !== document) {
- this.getRootNode().removeEventListener("focusin", this.handleDocumentFocusIn);
- }
- }
handleFocus() {
this.displayInput.setSelectionRange(0, 0);
}
@@ -602,6 +587,8 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
handleDisabledChange() {
if (this.disabled && this.open) {
this.open = false;
+ // The open watcher has already run for this update.
+ this.handleOpenChange();
}
}
handleValueChange() {
@@ -612,56 +599,72 @@ var WaSelect = class extends WebAwesomeFormAssociatedElement {
this.updateValidity();
}
async handleOpenChange() {
- if (this.open && !this.disabled) {
- this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
- const waShowEvent = new WaShowEvent();
- this.dispatchEvent(waShowEvent);
- if (waShowEvent.defaultPrevented) {
- this.open = false;
- return;
- }
- this.addOpenListeners();
+ const open = this.open && !this.disabled;
+ if (!this.isConnected || this.openTransition.open === open) return this.openAnimation;
+ if (open) this.setCurrentOption(this.selectedOptions[0] || this.getFirstOption());
+ const event = open ? new WaShowEvent() : new WaHideEvent();
+ this.dispatchEvent(event);
+ if (event.defaultPrevented && !this.disabled) {
+ this.open = this.openTransition.open;
+ return this.openAnimation;
+ }
+ if (!this.isConnected || (this.open && !this.disabled) !== open) return this.openAnimation;
+ this.openTransition.controller?.abort();
+ this.openTransition = { open, controller: new AbortController() };
+ const { signal } = this.openTransition.controller;
+ const isCurrent = () => !signal.aborted && this.isConnected && (this.open && !this.disabled) === open;
+ if (open) {
+ document.addEventListener("focusin", this.handleDocumentFocusIn, { signal });
+ document.addEventListener("keydown", this.handleDocumentKeyDown, { signal });
+ document.addEventListener("mousedown", this.handleDocumentMouseDown, { signal });
+ registerDismissible(this);
+ const root = this.getRootNode();
+ if (root !== document) root.addEventListener("focusin", this.handleDocumentFocusIn, { signal });
this.listbox.hidden = false;
this.popup.active = true;
requestAnimationFrame(() => {
- this.setCurrentOption(this.currentOption);
+ if (isCurrent()) this.setCurrentOption(this.currentOption);
});
- await animateWithClass(this.popup.popup, "show");
- if (this.currentOption) {
+ } else {
+ unregisterDismissible(this);
+ }
+ // Retire the old animation before joining its cleanup; only the accepted
+ // transition may publish visibility, focus, or completion after an await.
+ this.popup.popup.classList.remove("show", "hide");
+ const previousAnimation = this.openAnimation;
+ this.openAnimation = (async () => {
+ await previousAnimation;
+ if (!isCurrent()) return;
+ await animateWithClass(this.popup.popup, open ? "show" : "hide");
+ if (!isCurrent()) return;
+ if (open && this.currentOption) {
scrollIntoView(this.currentOption, this.listbox, "vertical", "auto");
}
- this.dispatchEvent(new WaAfterShowEvent());
- } else {
- const waHideEvent = new WaHideEvent();
- this.dispatchEvent(waHideEvent);
- if (waHideEvent.defaultPrevented) {
- this.open = false;
- return;
+ if (!open) {
+ this.listbox.hidden = true;
+ this.popup.active = false;
}
- this.removeOpenListeners();
- await animateWithClass(this.popup.popup, "hide");
- this.listbox.hidden = true;
- this.popup.active = false;
- this.dispatchEvent(new WaAfterHideEvent());
- }
+ this.dispatchEvent(open ? new WaAfterShowEvent() : new WaAfterHideEvent());
+ })();
+ return this.openAnimation;
}
/** Shows the listbox. */
async show() {
- if (this.open || this.disabled) {
+ if (this.disabled) {
this.open = false;
- return void 0;
+ return;
}
+ if (!this.isConnected) return;
this.open = true;
- return waitForEvent(this, "wa-after-show");
+ await this.updateComplete;
+ return this.handleOpenChange();
}
/** Hides the listbox. */
async hide() {
- if (!this.open || this.disabled) {
- this.open = false;
- return void 0;
- }
this.open = false;
- return waitForEvent(this, "wa-after-hide");
+ if (!this.isConnected) return;
+ await this.updateComplete;
+ return this.openAnimation;
}
/** Sets focus on the control. */
focus(options) {
diff --git a/dist-cdn/chunks/chunk.W2C46OQX.js b/dist-cdn/chunks/chunk.W2C46OQX.js
index da164574ce537b386894e751d6e9727cc9249d08..4625393889d6d1b92bddc212b8ba407843f70136 100644
--- a/dist-cdn/chunks/chunk.W2C46OQX.js
+++ b/dist-cdn/chunks/chunk.W2C46OQX.js
@@ -36,6 +36,7 @@ var WaDropdownItem = class extends WebAwesomeElement {
constructor() {
super(...arguments);
this.hasSlotController = new HasSlotController(this, "[default]", "start", "end");
+ this.submenuGeneration = 0;
this.active = false;
this.variant = "default";
this.size = "m";
@@ -47,7 +48,10 @@ var WaDropdownItem = class extends WebAwesomeElement {
this.submenuOpen = false;
this.hasSubmenu = false;
this.handleSlotChange = () => {
- this.hasSubmenu = this.hasSlotController.test("submenu");
+ const hasSubmenu = this.hasSlotController.test("submenu");
+ // Retire the branch before losing the old submenu state and popup.
+ if (!hasSubmenu) void this.closeSubmenu();
+ this.hasSubmenu = hasSubmenu;
this.updateHasSubmenuState();
if (this.hasSubmenu) {
this.setAttribute("aria-haspopup", "menu");
@@ -74,7 +78,6 @@ var WaDropdownItem = class extends WebAwesomeElement {
/** Handles pointer enter to open the submenu on hover */
this.handlePointerEnter = (event) => {
if (event.pointerType === "mouse" && this.hasSubmenu && !this.disabled) {
- this.notifyParentOfOpening();
this.submenuOpen = true;
}
};
@@ -91,7 +94,15 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
disconnectedCallback() {
super.disconnectedCallback();
- this.closeSubmenu();
+ this.submenuGeneration++;
+ void this.closeSubmenu();
+ const submenu = this.submenuElement;
+ if (submenu) {
+ submenu.classList.remove("show", "hide");
+ submenu.hidden = true;
+ submenu.removeAttribute("data-visible");
+ submenu.hidePopover?.();
+ }
this.removeEventListener?.("click", this.handleHostClick);
this.removeEventListener?.("pointerenter", this.handlePointerEnter);
this.shadowRoot?.removeEventListener?.("click", this.handleClick, { capture: true });
@@ -134,11 +145,8 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
if (changedProperties.has("submenuOpen")) {
this.customStates.set("submenu-open", this.submenuOpen);
- if (this.submenuOpen) {
- this.openSubmenu();
- } else {
- this.closeSubmenu();
- }
+ const previousAnimation = this.submenuAnimation;
+ this.submenuAnimation = this.updateSubmenuVisibility(previousAnimation);
}
}
/** Update the has-submenu custom state */
@@ -147,22 +155,51 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
/** Opens the submenu. */
async openSubmenu() {
- const submenu = this.submenuElement;
- if (!this.hasSubmenu || !submenu || !this.isConnected) return;
- this.notifyParentOfOpening();
- submenu.showPopover?.();
- submenu.hidden = false;
- submenu.setAttribute("data-visible", "");
+ if (!this.hasSubmenu || !this.submenuElement || !this.isConnected) return;
this.submenuOpen = true;
- this.setAttribute("aria-expanded", "true");
- await animateWithClass(submenu, "show");
- setTimeout(() => {
+ await this.updateComplete;
+ return this.submenuAnimation;
+ }
+ /** Reconciles property and public-method requests through one submenu lifecycle. */
+ async updateSubmenuVisibility(previousAnimation) {
+ const submenu = this.submenuElement;
+ if (!this.submenuOpen) this.dispatchEvent(new Event("submenu-closing"));
+ if (!this.hasSubmenu || !submenu || !this.isConnected) {
+ await previousAnimation;
+ return;
+ }
+ const open = this.submenuOpen;
+ const generation = ++this.submenuGeneration;
+ const isCurrent = () => generation === this.submenuGeneration && this.isConnected && this.submenuOpen === open;
+ // Retire the previous class before joining its cleanup. A stale close must never
+ // hide a reopened native popover, and repeated public requests share this promise.
+ submenu.classList.remove("show", "hide");
+ if (open) {
+ this.notifyParentOfOpening();
+ if (!isCurrent()) return;
+ submenu.showPopover?.();
+ submenu.hidden = false;
+ submenu.setAttribute("data-visible", "");
+ }
+ // Opening observers initialize embedded controls before the expanded state changes.
+ this.setAttribute("aria-expanded", String(open));
+ await previousAnimation;
+ await this.updateComplete;
+ if (!isCurrent()) return;
+ // Focus once when opening becomes usable, not again when the animation finishes:
+ // the operator may already have moved focus or returned to the parent by then.
+ if (open) {
const items = this.getSubmenuItems();
- if (items.length > 0) {
- items.forEach((item, index) => item.active = index === 0);
- items[0].focus({ preventScroll: true });
- }
- }, 0);
+ items.forEach((item, index) => item.active = index === 0);
+ items[0]?.focus({ preventScroll: true });
+ }
+ await animateWithClass(submenu, open ? "show" : "hide");
+ if (!isCurrent()) return;
+ if (!open) {
+ submenu.hidden = true;
+ submenu.removeAttribute("data-visible");
+ submenu.hidePopover?.();
+ }
}
/** Notifies the parent dropdown that this item is opening its submenu */
notifyParentOfOpening() {
@@ -172,30 +209,15 @@ var WaDropdownItem = class extends WebAwesomeElement {
detail: { item: this }
});
this.dispatchEvent(event);
- const parent = this.parentElement;
- if (parent) {
- const siblings = [...parent.children].filter(
- (el) => el !== this && el.localName === "wa-dropdown-item" && el.getAttribute("slot") === this.getAttribute("slot") && el.submenuOpen
- );
- siblings.forEach((sibling) => {
- sibling.submenuOpen = false;
- });
- }
}
/** Closes the submenu. */
async closeSubmenu() {
- const submenu = this.submenuElement;
- if (!this.hasSubmenu || !submenu) return;
+ // A never-connected item's first update cannot settle; closing it is a public no-op.
+ if (!this.hasSubmenu || !this.submenuElement) return;
this.submenuOpen = false;
- this.setAttribute("aria-expanded", "false");
- if (!submenu.hidden) {
- await animateWithClass(submenu, "hide");
- if (submenu?.isConnected) {
- submenu.hidden = true;
- submenu.removeAttribute("data-visible");
- submenu.hidePopover?.();
- }
- }
+ this.dispatchEvent(new Event("submenu-closing"));
+ await this.updateComplete;
+ return this.submenuAnimation;
}
/** Determines whether the item navigates when selected. Items with submenus never navigate. */
isLink() {
@@ -225,8 +247,9 @@ var WaDropdownItem = class extends WebAwesomeElement {
}
/** Gets all dropdown items in the submenu. */
getSubmenuItems() {
- return [...this.children].filter(
- (el) => el.localName === "wa-dropdown-item" && el.getAttribute("slot") === "submenu" && !el.hasAttribute("disabled")
+ const slot = this.submenuElement?.querySelector('slot[name="submenu"]');
+ return (slot?.assignedElements({ flatten: true }) ?? []).filter(
+ (el) => el.localName === "wa-dropdown-item" && !el.disabled
);
}
render() {
@@ -285,7 +308,7 @@ var WaDropdownItem = class extends WebAwesomeElement {
>
<slot name="submenu"></slot>
</div>
- ` : ""}
+ ` : html`<slot name="submenu" hidden></slot>`}
`;
}
};
diff --git a/dist-cdn/components/dropdown/dropdown.d.ts b/dist-cdn/components/dropdown/dropdown.d.ts
index 561ccfa258274c2c41cb728c0bfef26381c9f30c..7da1ccc5e1a55eeef5df886f612449832dc01157 100644
--- a/dist-cdn/components/dropdown/dropdown.d.ts
+++ b/dist-cdn/components/dropdown/dropdown.d.ts
@@ -29,11 +29,12 @@ import '../popup/popup.js';
*/
export default class WaDropdown extends WebAwesomeElement {
static css: import("lit").CSSResult[];
- private submenuCleanups;
private readonly localize;
private userTypedQuery;
private userTypedTimeout;
- private openSubmenuStack;
+ private activeSubmenus;
+ private menuTransition?;
+ private menuAnimation?;
defaultSlot: HTMLSlotElement;
private menu;
private popup;
@@ -51,6 +52,7 @@ export default class WaDropdown extends WebAwesomeElement {
distance: number;
/** The offset of the dropdown menu along its trigger. */
skidding: number;
+ connectedCallback(): void;
disconnectedCallback(): void;
firstUpdated(changedProperties: PropertyValues<typeof this>): void;
updated(changedProperties: PropertyValues<typeof this>): Promise<void>;
@@ -60,22 +62,14 @@ export default class WaDropdown extends WebAwesomeElement {
private getSubmenuItems;
/** Syncs item sizes with the dropdown's size property. */
private syncItemSizes;
- /** Handles the submenu navigation stack */
- private addToSubmenuStack;
- /** Removes the last item from the submenu stack */
- private removeFromSubmenuStack;
- /** Gets the current active submenu item */
+ /** Gets the current active submenu item. */
private getCurrentSubmenuItem;
- /** Closes all submenus in the dropdown. */
+ /** Retires an ancestor branch before its hide animations can complete. */
private closeAllSubmenus;
- /** Closes sibling submenus at the same level as the specified item. */
- private closeSiblingSubmenus;
/** Get the slotted trigger button, a <wa-button> or <button> element */
private getTrigger;
- /** Shows the dropdown menu. This should only be called from within updated(). */
- private showMenu;
- /** Hides the dropdown menu. This should only be called from within updated(). */
- private hideMenu;
+ /** Reconciles accepted visibility and its animation from updates or reconnection. */
+ private updateMenuVisibility;
/** Handles key down events when the menu is open */
private handleDocumentKeyDown;
/** Handles pointer down events when the dropdown is open. */
@@ -90,10 +84,7 @@ export default class WaDropdown extends WebAwesomeElement {
private handleSubmenuOpening;
/** Sets up submenu positioning with autoUpdate */
private setupSubmenuPosition;
- private static handleSubmenuSlotChange;
private processSubmenuItems;
- /** Cleans up submenu positioning */
- private cleanupSubmenuPosition;
/** Positions a submenu relative to its parent item */
private positionSubmenu;
/** Updates the safe triangle coordinates for a submenu */
diff --git a/dist-cdn/components/dropdown-item/dropdown-item.d.ts b/dist-cdn/components/dropdown-item/dropdown-item.d.ts
index 1e458bc4c742d94cd58d688acde8a45f9c681763..477eac752d3d2c0a81ee3a59a80c09b81d972888 100644
--- a/dist-cdn/components/dropdown-item/dropdown-item.d.ts
+++ b/dist-cdn/components/dropdown-item/dropdown-item.d.ts
@@ -35,6 +35,8 @@ import '../icon/icon.js';
export default class WaDropdownItem extends WebAwesomeElement {
static css: import("lit").CSSResult;
private readonly hasSlotController;
+ private submenuGeneration;
+ private submenuAnimation?;
submenuElement: HTMLDivElement;
private linkElement;
/** @internal The controller will set this property to true when the item is active. */
@@ -91,6 +93,8 @@ export default class WaDropdownItem extends WebAwesomeElement {
private updateHasSubmenuState;
/** Opens the submenu. */
openSubmenu(): Promise<void>;
+ /** Reconciles property and public-method requests through one submenu lifecycle. */
+ private updateSubmenuVisibility;
/** Notifies the parent dropdown that this item is opening its submenu */
private notifyParentOfOpening;
/** Closes the submenu. */
diff --git a/dist-cdn/components/select/select.d.ts b/dist-cdn/components/select/select.d.ts
index 9b4c748a83e5e990fd6eeb6367b37713b56773b2..f801fce20728a10630b6e47099a1054b464fb37c 100644
--- a/dist-cdn/components/select/select.d.ts
+++ b/dist-cdn/components/select/select.d.ts
@@ -66,6 +66,8 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
static get validators(): import("../../internal/webawesome-form-associated-element.js").Validator<WebAwesomeFormAssociatedElement>[];
assumeInteractionOn: string[];
private cachedOptions;
+ private openTransition;
+ private openAnimation;
private readonly hasSlotController;
private readonly localize;
private selectionOrder;
@@ -154,8 +156,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
connectedCallback(): void;
disconnectedCallback(): void;
private updateDefaultValue;
- private addOpenListeners;
- private removeOpenListeners;
private handleFocus;
private handleDocumentFocusIn;
private handleDocumentKeyDown;
diff --git a/dist-cdn/custom-elements.json b/dist-cdn/custom-elements.json
index 4128d582ee76895db1e7a8b55349680d84bfa40f..51fa14f3ad1234ce8fc7991fd83b002b30238d92 100644
--- a/dist-cdn/custom-elements.json
+++ b/dist-cdn/custom-elements.json
@@ -27842,13 +27842,13 @@
"privacy": "private"
},
{
- "kind": "method",
- "name": "addOpenListeners",
+ "kind": "field",
+ "name": "openTransition",
"privacy": "private"
},
{
- "kind": "method",
- "name": "removeOpenListeners",
+ "kind": "field",
+ "name": "openAnimation",
"privacy": "private"
},
{
diff --git a/dist-cdn/internal/animate.d.ts b/dist-cdn/internal/animate.d.ts
index bedb1923179f2a73be2eaf24e8c1a3c35718683d..04dc1c15097ad58de31a8670e2c870d3a475b96f 100644
--- a/dist-cdn/internal/animate.d.ts
+++ b/dist-cdn/internal/animate.d.ts
@@ -1,9 +1,6 @@
/** Same as `el.animate()`, except returns a promise that doesn't throw an error when the animation is canceled. */
export declare function animate(el: Element, keyframes: Keyframe[], options?: KeyframeAnimationOptions): Promise<void | Animation>;
-/**
- * Applies a class to the specified element to animate it. The class is removed after the animation finishes and then
- * the promise resolves. If a timeout is provided, the class will be removed and the animation will
- */
+/** Applies an animation class and removes it after its finite CSS animations finish or are canceled. */
export declare function animateWithClass(el: Element, className: string): Promise<void>;
/** Parses a CSS duration and returns the number of milliseconds. */
export declare function parseDuration(duration: number | string): number;