import { adopt, define, setState, sheet } from "../internals.ts"; import { icon } from "../icons/element.ts"; /** Fired on an item when it is chosen, after its menu has closed and focus is back on the trigger. */ export class MenuSelectEvent extends Event { constructor(readonly item: MenuItemElement) { super("menuselect", { bubbles: true }); } } const anchored = typeof CSS !== "undefined" && CSS.supports("position-area", "block-end"); const menuSheet = sheet(` :host { box-sizing: border-box; inset: auto; margin: var(--nib-space-2xs) 0; position-area: block-end span-inline-end; position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; min-inline-size: 12rem; max-inline-size: calc(100vw - 2 * var(--nib-space-sm)); max-block-size: calc(100dvh - 2 * var(--nib-space-sm)); overflow: auto; padding: var(--nib-space-2xs); border: var(--nib-size-border) solid var(--nib-color-border); border-radius: var(--nib-radius-md); background: var(--nib-color-surface); color: var(--nib-color-text); font: var(--nib-font-ui); box-shadow: 0 var(--nib-space-xs) var(--nib-space-lg) color-mix(in srgb, var(--nib-color-scrim) 50%, transparent); } ::slotted(hr) { margin: var(--nib-space-2xs) 0; border: 0; border-block-start: var(--nib-size-border) solid var(--nib-color-border); } @media (prefers-reduced-motion: no-preference) { :host { opacity: 0; transition: opacity 0.1s, display 0.1s allow-discrete, overlay 0.1s allow-discrete; } :host(:popover-open) { opacity: 1; @starting-style { opacity: 0; } } } `); /** * A WAI-ARIA menu opened by a menu button. Set `trigger` to the button: the menu becomes its popover * (`popover="auto"`, so outside clicks and Escape dismiss it), anchored below it and flipping to stay * in the viewport, and the button gets `aria-haspopup` and `aria-expanded`. Enter, Space, and * ArrowDown on the button open the menu at the first item and ArrowUp at the last; a pointer open * leaves focus on the button. In the menu, arrows wrap, Home and End jump, a letter jumps to the next * item starting with it, and Escape or Tab closes it. Closing returns focus to the button unless * focus already moved somewhere else. The role and name live on ElementInternals. */ export class MenuElement extends HTMLElement { readonly #internals = this.attachInternals(); #trigger: HTMLButtonElement | undefined; #unwire: AbortController | undefined; /** Which item a keyboard open lands on. */ #enter: "first" | "last" | undefined; /** Set while this element closes itself, so the toggle handler does not restore focus again. */ #closing = false; constructor() { super(); this.#internals.role = "menu"; const root = this.attachShadow({ mode: "open" }); adopt(root, menuSheet); root.append(document.createElement("slot")); this.addEventListener("keydown", (event) => this.#onKeydown(event)); this.addEventListener("click", (event) => { const item = event.composedPath().find((el): el is MenuItemElement => el instanceof MenuItemElement); if (item) this.#choose(item, event.detail !== 0); }); this.addEventListener("pointermove", (event) => { const item = event.composedPath().find((el): el is MenuItemElement => el instanceof MenuItemElement); if (item && !item.disabled && document.activeElement !== item) item.focus({ focusVisible: false } as FocusOptions); }); this.addEventListener("toggle", (event) => this.#onToggle(event as ToggleEvent)); } connectedCallback(): void { if (!this.hasAttribute("popover")) this.popover = "auto"; } get open(): boolean { return this.matches(":popover-open"); } get trigger(): HTMLButtonElement | undefined { return this.#trigger; } /** The menu button. It must sit in the same document or shadow root as the menu. */ set trigger(button: HTMLButtonElement | undefined) { this.#unwire?.abort(); this.#trigger = button; if (!button) return; const { signal } = (this.#unwire = new AbortController()); button.ariaHasPopup = "menu"; button.ariaExpanded = String(this.open); // Invoker commands toggle the menu natively and keep the button in its light-dismiss group, so a // click on the open button closes the menu instead of dismissing and reopening it. if ("commandForElement" in HTMLButtonElement.prototype) { Object.assign(button, { commandForElement: this, command: "toggle-popover" }); } else { button.popoverTargetElement = this; } this.#internals.ariaLabelledByElements = [button]; button.addEventListener( "click", (event) => { // A click with no detail came from Enter or Space, which enter the menu; a pointer does not. if (event.detail === 0 && !this.#enter) this.#enter = "first"; }, { signal }, ); button.addEventListener( "keydown", (event) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); const at = event.key === "ArrowDown" ? "first" : "last"; if (this.open) return this.#focusItem(at); this.#enter = at; button.click(); }, { signal }, ); } /** Closes the menu and puts focus back on the trigger. */ close(options: { focusVisible?: boolean } = {}): void { if (!this.open) return; this.#closing = true; this.#trigger?.focus({ focusVisible: options.focusVisible ?? true } as FocusOptions); this.hidePopover(); } #items(): MenuItemElement[] { return [...this.querySelectorAll("nib-menu-item")].filter((item) => !item.hidden && !item.disabled && item.closest("nib-menu") === this); } #focusItem(at: "first" | "last" | MenuItemElement | undefined) { const items = this.#items(); const item = at === "first" ? items[0] : at === "last" ? items.at(-1) : at; item?.focus(); } #choose(item: MenuItemElement, fromPointer: boolean) { if (item.disabled) return; this.close({ focusVisible: !fromPointer }); item.dispatchEvent(new MenuSelectEvent(item)); } #onToggle(event: ToggleEvent) { const trigger = this.#trigger; if (trigger) trigger.ariaExpanded = String(event.newState === "open"); if (event.newState === "open") { if (!anchored) this.#place(); const enter = this.#enter; this.#enter = undefined; if (enter) this.#focusItem(enter); return; } this.#enter = undefined; if (this.#closing) return void (this.#closing = false); // Dismissed from outside: an outside click that landed on nothing focusable leaves focus on the // body, so it goes back to the trigger. A click that focused something else keeps that focus. const active = deepActiveElement(); if (!active || active === document.body || this.contains(active)) trigger?.focus({ focusVisible: false } as FocusOptions); } #onKeydown(event: KeyboardEvent) { if (event.metaKey || event.ctrlKey || event.altKey) return; const items = this.#items(); const current = items.find((item) => item === deepActiveElement()); const at = current ? items.indexOf(current) : -1; switch (event.key) { case "ArrowDown": event.preventDefault(); return this.#focusItem(items[(at + 1) % items.length]); case "ArrowUp": event.preventDefault(); return this.#focusItem(at === -1 ? "last" : items[(at - 1 + items.length) % items.length]); case "Home": event.preventDefault(); return this.#focusItem("first"); case "End": event.preventDefault(); return this.#focusItem("last"); case "Escape": event.preventDefault(); return this.close(); case "Tab": // Focus goes back to the trigger first, so the Tab itself moves on from there. return this.close(); case "Enter": case " ": event.preventDefault(); current?.click(); return; } if (event.key.length === 1 && event.key.trim()) { const key = event.key.toLocaleLowerCase(); const ordered = [...items.slice(at + 1), ...items.slice(0, at + 1)]; const match = ordered.find((item) => item.textContent?.trim().toLocaleLowerCase().startsWith(key)); if (match) this.#focusItem(match); } } /** Without CSS anchor positioning, places the menu below the trigger, flipping and clamping to the viewport. */ #place() { const trigger = this.#trigger?.getBoundingClientRect(); if (!trigger) return; const menu = this.getBoundingClientRect(); const edge = 8; const gap = 4; let left = trigger.left; if (left + menu.width > innerWidth - edge) left = Math.max(edge, trigger.right - menu.width); let top = trigger.bottom + gap; if (top + menu.height > innerHeight - edge && trigger.top - gap - menu.height >= edge) top = trigger.top - gap - menu.height; Object.assign(this.style, { position: "fixed", margin: "0", left: `${left}px`, top: `${top}px` }); } } const itemSheet = sheet(` :host { display: flex; align-items: center; gap: var(--nib-space-sm); min-block-size: var(--nib-size-row); padding-inline: var(--nib-space-sm); border-radius: var(--nib-radius-sm); white-space: nowrap; cursor: default; outline: none; } :host([hidden]) { display: none; } /* A fixed leading slot keeps labels aligned whether or not an item can be checked. */ [part="check"] { display: none; flex: none; align-items: center; justify-content: center; inline-size: 1em; } :host(:state(checkable)) [part="check"] { display: inline-flex; } [part="check"] svg { visibility: hidden; } :host(:state(checked)) [part="check"] svg { visibility: visible; } :host(:focus) { background: var(--nib-color-hover); } :host(:focus-visible) { outline: var(--nib-size-focus-ring) solid var(--nib-color-focus-ring); outline-offset: calc(-1 * var(--nib-size-focus-ring)); } :host(:state(disabled)) { color: var(--nib-color-text-muted); } @media (pointer: coarse) { :host { min-block-size: var(--nib-size-touch-target); } } `); /** * One item of a ``. Its light DOM is the label. Setting `checked` to a boolean makes it a * `menuitemradio`, for one-of-a-set choices inside a ``. */ export class MenuItemElement extends HTMLElement { readonly #internals = this.attachInternals(); value = ""; #checked: boolean | undefined; constructor() { super(); this.#internals.role = "menuitem"; const root = this.attachShadow({ mode: "open" }); adopt(root, itemSheet); const check = document.createElement("span"); check.part.add("check"); // icon(), not : every item shares this item's own shadow root's one sprite. check.append(icon(root, "check")); root.append(check, document.createElement("slot")); } connectedCallback(): void { this.tabIndex = -1; } get checked(): boolean | undefined { return this.#checked; } set checked(value: boolean | undefined) { this.#checked = value; this.#internals.role = value === undefined ? "menuitem" : "menuitemradio"; this.#internals.ariaChecked = value === undefined ? null : String(value); setState(this.#internals, "checkable", value !== undefined); setState(this.#internals, "checked", value === true); } get disabled(): boolean { return this.#internals.ariaDisabled === "true"; } set disabled(value: boolean) { this.#internals.ariaDisabled = value ? "true" : null; setState(this.#internals, "disabled", value); } } const groupSheet = sheet(` :host { display: block; } [part="label"] { padding: var(--nib-space-xs) var(--nib-space-sm) var(--nib-space-2xs); color: var(--nib-color-text-muted); font-size: var(--nib-font-size-small); } `); /** A labeled group of items, such as the radio items of one choice. The label shows above them. */ export class MenuGroupElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #label = Object.assign(document.createElement("div"), { part: "label" }); constructor() { super(); this.#internals.role = "group"; this.#label.ariaHidden = "true"; this.#label.hidden = true; const root = this.attachShadow({ mode: "open" }); adopt(root, groupSheet); root.append(this.#label, document.createElement("slot")); } get label(): string { return this.#internals.ariaLabel ?? ""; } set label(value: string) { this.#internals.ariaLabel = value; this.#label.textContent = value; this.#label.hidden = !value; } } /** The focused element, looking through open shadow roots. */ function deepActiveElement(): Element | null { let active = document.activeElement; while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; return active; } declare global { interface HTMLElementTagNameMap { "nib-menu": MenuElement; "nib-menu-item": MenuItemElement; "nib-menu-group": MenuGroupElement; } } define("nib-menu", MenuElement); define("nib-menu-item", MenuItemElement); define("nib-menu-group", MenuGroupElement);