//
// The trigger is `[data-ss-dropdown-toggle]`, else `[aria-haspopup]`, else
// the first button. The manager toggles `is-open` on the root (the module
// hides the menu without it), keeps `aria-expanded`/`aria-controls` on the
// trigger, closes on Escape (returning focus) and on a click outside, and
// moves focus through the items with the arrow keys, Home and End.
//
// A native `` opens by itself; the manager
// only adds the closing.
//
// Either way the menu also closes when a link in it is followed (a click on
// `.ss-c-dropdown__menu a[href]`), so a same-page link or a page restored
// from the back/forward cache does not show it still open, and on a
// `pageshow` that restores the page from that cache. A link that should
// leave the menu open (a filter toggle, say) opts out with
// `data-ss-dropdown-keep-open`, on the link or on an element between it and
// the root (the root itself included).
// ============================================================================
const ITEMS =
".ss-c-dropdown__menu a[href], .ss-c-dropdown__menu button:not([disabled]), .ss-c-dropdown__menu [role='menuitem']";
const MENU_LINK = ".ss-c-dropdown__menu a[href]";
const KEEP_OPEN = "[data-ss-dropdown-keep-open]";
let uid = 0;
export class DropdownManager {
readonly root: HTMLElement;
readonly trigger: HTMLElement | null;
readonly menu: HTMLElement | null;
private readonly isDetails: boolean;
private readonly onTriggerClick = (event: Event): void => {
event.preventDefault();
this.toggle();
};
private readonly onDocumentClick = (event: Event): void => {
if (this.isOpen && !this.root.contains(event.target as Node)) {
this.close(false);
}
};
private readonly onRootClick = (event: Event): void => {
if (!this.isOpen || !(event.target instanceof Element)) return;
const link = event.target.closest(MENU_LINK);
if (!link || !this.root.contains(link)) return;
const keep = link.closest(KEEP_OPEN);
if (keep && this.root.contains(keep)) return;
this.close(false);
};
private readonly onPageShow = (event: PageTransitionEvent): void => {
if (event.persisted && this.isOpen) this.close(false);
};
private readonly onKeydown = (event: KeyboardEvent): void => {
if (event.key === "Escape" && this.isOpen) {
event.preventDefault();
this.close(true);
return;
}
if (this.isDetails) return;
const items = this.items();
if (!items.length) return;
const current = items.indexOf(document.activeElement as HTMLElement);
let next: number | null = null;
if (event.key === "ArrowDown")
next = current < 0 ? 0 : (current + 1) % items.length;
else if (event.key === "ArrowUp")
next =
current < 0
? items.length - 1
: (current - 1 + items.length) % items.length;
else if (event.key === "Home" && current >= 0) next = 0;
else if (event.key === "End" && current >= 0) next = items.length - 1;
if (next === null) return;
event.preventDefault();
if (!this.isOpen) this.open();
items[next].focus();
};
constructor(root: HTMLElement | string) {
const el =
typeof root === "string"
? document.querySelector(root)
: root;
if (!el) throw new Error("DropdownManager: root element not found");
this.root = el;
this.isDetails = el instanceof HTMLDetailsElement;
this.menu = el.querySelector(".ss-c-dropdown__menu");
this.trigger = this.isDetails
? el.querySelector("summary")
: el.querySelector("[data-ss-dropdown-toggle]") ||
el.querySelector("[aria-haspopup]") ||
el.querySelector("button");
if (!this.isDetails && this.trigger) {
if (this.menu) {
if (!this.menu.id) this.menu.id = `ss-dropdown-${++uid}`;
this.trigger.setAttribute("aria-controls", this.menu.id);
}
this.trigger.setAttribute(
"aria-expanded",
String(el.classList.contains("is-open")),
);
this.trigger.addEventListener("click", this.onTriggerClick);
}
el.addEventListener("keydown", this.onKeydown);
el.addEventListener("click", this.onRootClick);
document.addEventListener("click", this.onDocumentClick);
window.addEventListener("pageshow", this.onPageShow);
}
get isOpen(): boolean {
return this.isDetails
? (this.root as HTMLDetailsElement).open
: this.root.classList.contains("is-open");
}
open(): void {
this.set(true);
}
close(returnFocus = false): void {
this.set(false);
if (returnFocus) this.trigger?.focus();
}
toggle(): void {
this.set(!this.isOpen);
}
destroy(): void {
this.trigger?.removeEventListener("click", this.onTriggerClick);
this.root.removeEventListener("keydown", this.onKeydown);
this.root.removeEventListener("click", this.onRootClick);
document.removeEventListener("click", this.onDocumentClick);
window.removeEventListener("pageshow", this.onPageShow);
}
/** Bind every `[data-ss="dropdown-menu"]` under `root`. */
static initAll(root: ParentNode = document): DropdownManager[] {
return Array.from(
root.querySelectorAll("[data-ss='dropdown-menu']"),
).map((el) => new DropdownManager(el));
}
private set(open: boolean): void {
if (this.isDetails) {
(this.root as HTMLDetailsElement).open = open;
return;
}
this.root.classList.toggle("is-open", open);
this.trigger?.setAttribute("aria-expanded", String(open));
}
private items(): HTMLElement[] {
return Array.from(this.root.querySelectorAll(ITEMS));
}
}
export default DropdownManager;