Menubar
A bar of application menus (File, Edit, View) composed from menu primitives and utilities.
Menubars are composed from primitives: like Toolbar, there is no separate .ui-menubar class or stylesheet. A utility container (inline-flex items-center gap-px bg-card rounded-md shadow-sm p-xs) holds a row of Menu primitives. Each trigger carries popovertarget and interestfor pointing at its panel so menus open on click or hover. Because only one popover="auto" can be open per stack, hovering an adjacent trigger closes the previous menu automatically, giving you the classic menubar glide without JavaScript.
Default
<div class="flex w-full items-center gap-px bg-card border-b p-xs" aria-label="Application menu"> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-example-file" interestfor="menubar-example-file" > File </button> <div id="menubar-example-file" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> New window </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Open... </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Save </button> </li> <hr class="ui-separator my-xs" /> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Close window </button> </li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-example-edit" interestfor="menubar-example-edit" > Edit </button> <div id="menubar-example-edit" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Undo </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Redo </button> </li> <hr class="ui-separator my-xs" /> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Cut </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Copy </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Paste </button> </li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-example-view" interestfor="menubar-example-view" > View </button> <div id="menubar-example-view" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Zoom in </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Zoom out </button> </li> <li> <button class="ui-button w-full justify-between" data-variant="ghost" type="button"> Full screen </button> </li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-example-help" interestfor="menubar-example-help" > Help </button> <div id="menubar-example-help" data-slot="menu-popover" data-side="bottom" data-align="end" popover="auto" > <menu> <li> <a href="#" class="ui-button w-full justify-between" data-variant="ghost" >Documentation</a > </li> <li> <a href="#" class="ui-button w-full justify-between" data-variant="ghost" >Keyboard shortcuts</a > </li> <li> <a href="#" class="ui-button w-full justify-between" data-variant="ghost" >Report an issue</a > </li> </menu> </div> </ui-menu></div>// primitives/menu/menu.js"use strict";/** * @fileoverview `<ui-menu>`: HTML web component for keyboard-enhanced menus. * @description Light-DOM custom element that augments the CSS-only menu * pattern (trigger + anchored `[data-slot~="menu-popover"]` panel) with * arrow-key navigation. The class form `.ui-menu` stays fully functional * without JavaScript: the Popover API provides open/close, light dismiss, * and focus return on its own. * * Keyboard behavior: * - ArrowDown / ArrowUp on the closed trigger open the panel and focus the * first / last item. * - ArrowDown / ArrowUp inside the open panel move focus between items, * wrapping around and skipping disabled items. * - Home / End jump to the first / last item. * - Escape and light dismiss are native Popover API behavior (no code here). * * The menu keeps the honest disclosure posture: items are plain links and * buttons, and no `role="menu"` is claimed. Add the full ARIA menu contract * yourself only if every item is an action and you implement the rest of the * pattern (typeahead, close-on-activate). * * @example * <ui-menu> * <button class="ui-button" type="button" popovertarget="m1">Open</button> * <div id="m1" data-slot="menu-popover" popover="auto"> * <menu> * <li><a href="/docs" class="ui-button justify-start" data-variant="ghost">Docs</a></li> * </menu> * </div> * </ui-menu> */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiMenu extends ZazzElement { setup(signal) { this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); } /** * @description The menu's own panel: a direct child so nested menus keep * their panels to themselves. * * @returns The panel element, or null when the markup is incomplete. */ #panel() { const panel = this.querySelector(':scope > [data-slot~="menu-popover"]'); return panel instanceof HTMLElement ? panel : null; } /** * @description Focusable items inside the panel, in DOM order. * * @param panel - The menu panel. * @returns Enabled links and buttons the arrow keys move between. */ #items(panel) { return Array.from(panel.querySelectorAll("a[href], button")) .filter((node) => node instanceof HTMLElement) .filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true" && item.closest("ui-menu, .ui-menu") === this); } /** * @description Routes arrow-key, Home, and End presses: opens the panel from * the trigger, or moves focus between items inside the open panel. * * @param event - The keydown event. */ #onKeydown(event) { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const target = event.target; if (!(target instanceof HTMLElement)) return; // Ignore keys that belong to a nested ui-menu if (target.closest("ui-menu") !== this) return; const panel = this.#panel(); if (!panel) return; const isTrigger = target.parentElement === this && (target.hasAttribute("popovertarget") || target.hasAttribute("interestfor")); const open = panel.matches(":popover-open, .\\:popover-open"); if (isTrigger && !open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); panel.showPopover(); const items = this.#items(panel); items[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus(); return; } if (!open || !panel.contains(target)) return; const items = this.#items(panel); if (items.length === 0) return; const index = items.indexOf(target); let nextIndex; switch (event.key) { case "ArrowDown": nextIndex = index === -1 ? 0 : (index + 1) % items.length; break; case "ArrowUp": nextIndex = index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = items.length - 1; break; default: return; } event.preventDefault(); items[nextIndex].focus(); }}defineZazzElement("ui-menu", UiMenu);export { UiMenu };Help menu with search
A menu panel can hold richer content than a list: here the Help menu embeds a Combobox for searching topics.
<div class="flex w-full items-center gap-px bg-card border-b p-xs" aria-label="Application menu"> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-search-file" interestfor="menubar-search-file" > File </button> <div id="menubar-search-file" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li> <button class="ui-button justify-start" data-variant="ghost" type="button"> New window </button> </li> <li> <button class="ui-button justify-start" data-variant="ghost" type="button">Open…</button> </li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" data-variant="ghost" type="button" popovertarget="menubar-search-help" interestfor="menubar-search-help" > Help </button> <div id="menubar-search-help" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <div class="flex flex-col gap-xs p-xs" style="min-inline-size: 20rem"> <label class="font-strong text-eyebrow text-muted-foreground" for="menubar-help-topic" >Search help topics</label > <ui-combobox> <input type="hidden" name="help-topic" data-slot="combobox-value" value="" /> <div data-slot="combobox-control"> <input id="menubar-help-topic" type="text" placeholder="Find a topic…" role="combobox" aria-expanded="true" aria-autocomplete="list" autocomplete="off" /> </div> <!-- No [popover] = the inline variant: the list renders in flow, always open --> <div data-slot="combobox-panel"> <ul role="listbox" data-slot="combobox-list" aria-label="Help topics"> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="shortcuts" > Keyboard shortcuts </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="theming" > Theming and dark mode </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="updates" > Checking for updates </li> </ul> <p data-slot="combobox-empty" class="text-muted-foreground">No topics found.</p> </div> </ui-combobox> </div> </div> </ui-menu></div>// primitives/menu/menu.js"use strict";/** * @fileoverview `<ui-menu>`: HTML web component for keyboard-enhanced menus. * @description Light-DOM custom element that augments the CSS-only menu * pattern (trigger + anchored `[data-slot~="menu-popover"]` panel) with * arrow-key navigation. The class form `.ui-menu` stays fully functional * without JavaScript: the Popover API provides open/close, light dismiss, * and focus return on its own. * * Keyboard behavior: * - ArrowDown / ArrowUp on the closed trigger open the panel and focus the * first / last item. * - ArrowDown / ArrowUp inside the open panel move focus between items, * wrapping around and skipping disabled items. * - Home / End jump to the first / last item. * - Escape and light dismiss are native Popover API behavior (no code here). * * The menu keeps the honest disclosure posture: items are plain links and * buttons, and no `role="menu"` is claimed. Add the full ARIA menu contract * yourself only if every item is an action and you implement the rest of the * pattern (typeahead, close-on-activate). * * @example * <ui-menu> * <button class="ui-button" type="button" popovertarget="m1">Open</button> * <div id="m1" data-slot="menu-popover" popover="auto"> * <menu> * <li><a href="/docs" class="ui-button justify-start" data-variant="ghost">Docs</a></li> * </menu> * </div> * </ui-menu> */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiMenu extends ZazzElement { setup(signal) { this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); } /** * @description The menu's own panel: a direct child so nested menus keep * their panels to themselves. * * @returns The panel element, or null when the markup is incomplete. */ #panel() { const panel = this.querySelector(':scope > [data-slot~="menu-popover"]'); return panel instanceof HTMLElement ? panel : null; } /** * @description Focusable items inside the panel, in DOM order. * * @param panel - The menu panel. * @returns Enabled links and buttons the arrow keys move between. */ #items(panel) { return Array.from(panel.querySelectorAll("a[href], button")) .filter((node) => node instanceof HTMLElement) .filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true" && item.closest("ui-menu, .ui-menu") === this); } /** * @description Routes arrow-key, Home, and End presses: opens the panel from * the trigger, or moves focus between items inside the open panel. * * @param event - The keydown event. */ #onKeydown(event) { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const target = event.target; if (!(target instanceof HTMLElement)) return; // Ignore keys that belong to a nested ui-menu if (target.closest("ui-menu") !== this) return; const panel = this.#panel(); if (!panel) return; const isTrigger = target.parentElement === this && (target.hasAttribute("popovertarget") || target.hasAttribute("interestfor")); const open = panel.matches(":popover-open, .\\:popover-open"); if (isTrigger && !open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); panel.showPopover(); const items = this.#items(panel); items[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus(); return; } if (!open || !panel.contains(target)) return; const items = this.#items(panel); if (items.length === 0) return; const index = items.indexOf(target); let nextIndex; switch (event.key) { case "ArrowDown": nextIndex = index === -1 ? 0 : (index + 1) % items.length; break; case "ArrowUp": nextIndex = index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = items.length - 1; break; default: return; } event.preventDefault(); items[nextIndex].focus(); }}defineZazzElement("ui-menu", UiMenu);export { UiMenu };// base/command-score.js"use strict";/** * @fileoverview Fuzzy command-scoring for the typeahead family. * @description Rates how well a search query matches a candidate string, * returning 0 (no match) to 1 (perfect continuous match). Continuous runs and * word-boundary jumps score high; scattered character jumps, transpositions, * case mismatches, and skipped characters decay the score. Autocomplete, * combobox, and command all rank their items with this single function. * * Vendored from cmdk's `command-score.ts` by @pacocoursey (MIT), itself * adapted from Superhuman's `command-score` (MIT), which builds on Joshaven * Potter's `string_score`. The constants and recursion are kept verbatim so * results match cmdk's ranking; only the module shape is Zazz's. * * @see https://github.com/pacocoursey/cmdk * @see https://github.com/superhuman/command-score */// --- Scoring constants ---// The scores are arranged so that a continuous match of characters will// result in a total score of 1.//// The best case: this character is a match, and either this is the start of// the string or the previous character was also a match.const SCORE_CONTINUE_MATCH = 1;// A new match at the start of a word scores better than a new match// elsewhere, as it's more likely that the user will type the starts of// fragments. Word jumps between spaces score slightly higher than slashes,// brackets, hyphens, etc.const SCORE_SPACE_WORD_JUMP = 0.9;const SCORE_NON_SPACE_WORD_JUMP = 0.8;// Any other match isn't ideal, but is included for completeness.const SCORE_CHARACTER_JUMP = 0.17;// If the user transposed two letters, it should be significantly penalized:// "ouch" is more likely than "curtain" when "uc" is typed.const SCORE_TRANSPOSITION = 0.1;// The goodness of a match decays slightly with each skipped character:// "bad" is more likely than "bard" when "bd" is typed.const PENALTY_SKIPPED = 0.999;// An exact-case match beats a case-insensitive match by a small amount:// "HTML" is more likely than "haml" when "HM" is typed.const PENALTY_CASE_MISMATCH = 0.9999;// If the candidate has more characters than the user typed, penalize// slightly: "html" is more likely than "html5" when "html" is typed.const PENALTY_NOT_COMPLETE = 0.99;const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;const IS_SPACE_REGEXP = /[\s-]/;const COUNT_SPACE_REGEXP = /[\s-]/g;// --- Scoring ---/** * @description Recursive scorer over (candidate index, query index) pairs, * memoized per pair so repeated subproblems resolve in constant time. * * @param target - The candidate string (original casing). * @param query - The query string (original casing). * @param lowerTarget - Pre-lowercased candidate. * @param lowerQuery - Pre-lowercased query. * @param targetIndex - Current position in the candidate. * @param queryIndex - Current position in the query. * @param memo - Shared memoization table for this scoring run. * @returns The best score reachable from this position. * @private */function commandScoreInner(target, query, lowerTarget, lowerQuery, targetIndex, queryIndex, memo) { if (queryIndex === query.length) { if (targetIndex === target.length) { return SCORE_CONTINUE_MATCH; } return PENALTY_NOT_COMPLETE; } const memoKey = `${targetIndex},${queryIndex}`; const memoized = memo[memoKey]; if (memoized !== undefined) { return memoized; } const queryChar = lowerQuery.charAt(queryIndex); let index = lowerTarget.indexOf(queryChar, targetIndex); let highScore = 0; while (index >= 0) { let score = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 1, memo); if (score > highScore) { if (index === targetIndex) { score *= SCORE_CONTINUE_MATCH; } else if (IS_GAP_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_NON_SPACE_WORD_JUMP; const wordBreaks = target.slice(targetIndex, index - 1).match(COUNT_GAPS_REGEXP); if (wordBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); } } else if (IS_SPACE_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_SPACE_WORD_JUMP; const spaceBreaks = target.slice(targetIndex, index - 1).match(COUNT_SPACE_REGEXP); if (spaceBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); } } else { score *= SCORE_CHARACTER_JUMP; if (targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, index - targetIndex); } } if (target.charAt(index) !== query.charAt(queryIndex)) { score *= PENALTY_CASE_MISMATCH; } } if ((score < SCORE_TRANSPOSITION && lowerTarget.charAt(index - 1) === lowerQuery.charAt(queryIndex + 1)) || // Allow duplicate letters (cmdk ref #7428) (lowerQuery.charAt(queryIndex + 1) === lowerQuery.charAt(queryIndex) && lowerTarget.charAt(index - 1) !== lowerQuery.charAt(queryIndex))) { const transposedScore = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 2, memo); if (transposedScore * SCORE_TRANSPOSITION > score) { score = transposedScore * SCORE_TRANSPOSITION; } } if (score > highScore) { highScore = score; } index = lowerTarget.indexOf(queryChar, index + 1); } memo[memoKey] = highScore; return highScore;}/** * @description Lowercases a string and folds every space-like character to a * plain space so variants match each other. * * @param value - The string to normalize. * @returns The normalized string. * @private */function formatInput(value) { return value.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");}/** * @description Scores how well `query` matches `target`, optionally widening * the candidate with alias strings (extra keywords that should also match). * * @param target - The candidate string to score against. * @param query - What the user typed. * @param aliases - Extra match targets appended to the candidate. * @returns 0 (no match) to 1 (perfect continuous match). */function commandScore(target, query, aliases = []) { const haystack = aliases.length > 0 ? `${target} ${aliases.join(" ")}` : target; return commandScoreInner(haystack, query, formatInput(haystack), formatInput(query), 0, 0, {});}export { commandScore };// base/hotkeys.js"use strict";// --- Parsing ---/** * @description Parses a `"mod+shift+p"`-style spec into a structured hotkey. * * @param spec - The hotkey spec, `+`-separated, case-insensitive. * @returns The parsed hotkey, or null for an empty/invalid spec. */function parseHotkey(spec) { const tokens = spec .split("+") .map((token) => token.trim().toLowerCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return null; const hotkey = { key: "", ctrl: false, alt: false, shift: false, meta: false, mod: false, }; for (const token of tokens.slice(0, -1)) { switch (token) { case "mod": hotkey.mod = true; break; case "ctrl": case "control": hotkey.ctrl = true; break; case "alt": case "option": hotkey.alt = true; break; case "shift": hotkey.shift = true; break; case "meta": case "cmd": case "super": hotkey.meta = true; break; default: // Unknown modifier: reject rather than silently matching too much return null; } } hotkey.key = tokens[tokens.length - 1]; return hotkey;}// --- Matching ---/** * @description Whether the current platform treats Meta as the primary * modifier (macOS, iOS, iPadOS). * * @returns True on Apple platforms. * @private */function isApplePlatform() { if (typeof navigator === "undefined") return false; return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);}/** * @description Matches a keyboard event against a parsed hotkey. Modifier * states must match exactly (`mod+k` does not fire on `mod+shift+k`) except * that `mod` claims whichever of Meta/Control the platform assigns it. * * @param event - The keyboard event to test. * @param hotkey - The parsed hotkey to match. * @param apple - Platform override for tests; defaults to detection. * @returns True when the event is exactly this hotkey. */function matchesHotkey(event, hotkey, apple = isApplePlatform()) { const wantMeta = hotkey.meta || (hotkey.mod && apple); const wantCtrl = hotkey.ctrl || (hotkey.mod && !apple); if (event.metaKey !== wantMeta) return false; if (event.ctrlKey !== wantCtrl) return false; if (event.altKey !== hotkey.alt) return false; if (event.shiftKey !== hotkey.shift) return false; return event.key.toLowerCase() === hotkey.key;}/** * @description Whether a node is an editable context (form field or * contenteditable), where bare-key hotkeys must not fire. * * @param node - The event target to inspect. * @returns True when typing belongs to the node, not to hotkeys. */function isEditableTarget(node) { if (!(node instanceof HTMLElement)) return false; if (node.isContentEditable) return true; return (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement);}/** * @description Whether a hotkey uses no modifier at all (a bare key), which * should be suppressed in editable contexts. * * @param hotkey - The parsed hotkey. * @returns True when no modifier is required. */function isBareKey(hotkey) { return !hotkey.ctrl && !hotkey.alt && !hotkey.meta && !hotkey.mod;}/** * @description Binds a document-level listener for one hotkey spec. Bare-key * specs are suppressed while focus is in an editable context; matches call * `preventDefault()` before the callback. * * @param spec - The hotkey spec, e.g. `"mod+k"`. * @param callback - Runs on each match, receiving the keyboard event. * @param options - `signal` unbinds the listener when aborted. * @returns True when the spec parsed and the listener was bound. */function bindHotkey(spec, callback, options = {}) { const hotkey = parseHotkey(spec); if (!hotkey || typeof document === "undefined") return false; document.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.repeat) return; if (isBareKey(hotkey) && isEditableTarget(event.target)) return; if (!matchesHotkey(event, hotkey)) return; event.preventDefault(); callback(event); }, { signal: options.signal }); return true;}export { parseHotkey, matchesHotkey, isEditableTarget, isBareKey, bindHotkey };// base/typeahead.js"use strict";/** * @fileoverview Shared typeahead engine for autocomplete, combobox, and command. * @description The common runtime behind the filter-as-you-type family: a * query signal fed by the input, score-based ranking of real DOM items * (`commandScore`), active-item keyboard navigation with * `aria-activedescendant` (focus never leaves the input), and, for form * controls whose panel sits outside the input, ownership of a * `popover="manual"` panel with outside-pointerdown and Escape close paths. * * Subclasses (`UiAutocomplete`, `UiCombobox`, `UiCommand`) declare their slot * prefix and commit semantics; everything else lives here. Filtering only * writes `hidden` and (when ranking) inline `order`: the DOM is never * restructured, so forms, focus, and progressive enhancement stay untouched. * Groups and empty states hide with CSS `:has()`, not code. * * Item facts come from the markup: the match/commit text is `data-value` * (falling back to text content) and `data-keywords` adds extra match * targets. `data-sort="score"` on the root re-ranks visually via `order`. */import { commandScore } from "./command-score.js";import { ZazzElement } from "./zazz-element.js";import { effect, state } from "./signals.js";/** * @description Scores every item against the query. An empty query leaves all * items visible with a neutral score. * * @param query - What the user typed. * @param items - Facts for each item, in DOM order. * @returns One verdict per item, same order as the input. */function rankItems(query, items) { const trimmed = query.trim(); return items.map((item, index) => { const score = trimmed === "" ? 1 : commandScore(item.value, trimmed, item.keywords); return { index, score, hidden: score <= 0 }; });}/** * @description Reducer for active-item keyboard navigation. ArrowDown from * nothing highlights the first item, ArrowUp from nothing the last; both wrap. * * @param current - The currently active index, -1 for none. * @param key - The `KeyboardEvent.key` pressed. * @param count - How many items are visible. * @returns The next active index, -1 when there is nothing to highlight. */function nextActiveIndex(current, key, count) { if (count === 0) return -1; switch (key) { case "ArrowDown": return current < 0 ? 0 : (current + 1) % count; case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count; case "Home": return 0; case "End": return count - 1; default: return current; }}// --- Element base ---let typeaheadIdCounter = 0;/** * @description Base class for the typeahead family. Subclasses set the slot * prefix and commit behavior; the base owns query state, ranking, keyboard * navigation, ARIA wiring, and (when `managesPanel`) the manual popover. */class TypeaheadElement extends ZazzElement { /** Whether this element opens/closes its own `popover="manual"` panel. */ managesPanel = true; /** * Whether focusing the input opens the panel. Combobox opts out: its input * holds a committed display value, so tabbing through a form must not pop * the list open, any more than tabbing to a select opens its picker. */ openOnFocus = true; /** Whether ranking also re-orders visually via inline `order`. */ get sortByScore() { return this.getAttribute("data-sort") === "score"; } /** Whether filtering auto-highlights the best item (command palettes do). */ autoHighlight = false; searchInput = null; panel = null; query = state(""); open = state(false); activeIndex = state(-1); setup(signal) { const prefix = this.slotPrefix; const panel = this.querySelector(`[data-slot~="${prefix}-panel"]`); const input = this.querySelector('input[role="combobox"]') ?? this.querySelector(`[data-slot~="${prefix}-input"]`); if (!(panel instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return; this.panel = panel; this.searchInput = input; const list = panel.querySelector(`[data-slot~="${prefix}-list"]`); if (list instanceof HTMLElement) { list.id ||= `ui-${prefix}-list-${++typeaheadIdCounter}`; input.setAttribute("aria-controls", list.id); } // Input adapters: DOM events only write signals input.addEventListener("input", () => { this.query.set(input.value); this.activeIndex.set(this.autoHighlight ? 0 : -1); if (this.managesPanel && !this.#inlinePanel()) { this.open.set(input.value.length >= this.#minLength()); } }, { signal }); input.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); if (this.#inlinePanel()) { // Inline variant: a panel without [popover] renders in flow and is // always open; there is nothing to show, hide, or light-dismiss this.open.set(true); input.setAttribute("aria-expanded", "true"); } else if (this.managesPanel) { if (this.openOnFocus) { input.addEventListener("focus", () => { if (input.value.length >= this.#minLength()) this.open.set(true); }, { signal }); } // Outside pointerdown closes; inside the panel it must not steal focus document.addEventListener("pointerdown", (event) => { if (!(event.target instanceof Node)) return; if (this.contains(event.target)) return; this.open.set(false); }, { signal }); this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; this.open.set(false); }, { signal }); } else if (panel instanceof HTMLDialogElement) { // Native <dialog> surface: mirror the dialog-lifecycle events this.addEventListener("zazz:dialog-open", (event) => { if (event.target !== panel) return; this.open.set(true); input.focus(); }, { signal }); this.addEventListener("zazz:dialog-close", (event) => { if (event.target !== panel) return; this.open.set(false); }, { signal }); } else { // Native popover="auto" surface owns open/close; mirror it panel.addEventListener("toggle", (event) => { const opened = event.newState === "open"; this.open.set(opened); if (opened) input.focus(); }, { signal }); } // Keep focus in the input. List rows are not focusable, so a mousedown on // one blurs the input, and the resulting focusout closes the panel (and, in // combobox, reverts the query) *before* the click that commits, and the // commit's own input.focus() then re-fires the focus handler that reopens // it. mousedown, not pointerdown: preventing pointerdown would also cancel // touch panning inside the scrollable panel. Scoped to the list rather than // the whole panel so native scrollbar dragging and header text selection // survive. Suppressing focus transfer does not suppress the click, so link // and invoker items still activate. panel.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element)) return; if (event.target.closest("input, textarea, select, [contenteditable]")) return; if (!event.target.closest(`[data-slot~="${prefix}-list"]`)) return; event.preventDefault(); }, { signal }); // Item click = commit (the mousedown guard above keeps focus in the input, // so no focusout races the click) panel.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const item = event.target.closest(`[data-slot~="${prefix}-item"]`); if (item && !item.hidden) this.commit(item, "pointer"); }, { signal }); // Output adapter 1: panel visibility and expanded state (the inline // variant is unconditionally open, so it binds no visibility effect) if (this.#inlinePanel()) { // Nothing to drive } else if (this.managesPanel) { effect(() => { const opened = this.open.get(); input.setAttribute("aria-expanded", String(opened)); if (opened && !this.#panelOpen()) panel.showPopover(); else if (!opened && this.#panelOpen()) panel.hidePopover(); if (!opened) this.activeIndex.set(-1); }, { signal }); } else { effect(() => { input.setAttribute("aria-expanded", String(this.open.get())); }, { signal }); } // Output adapter 2: ranking, visibility, highlight, activedescendant. // All three signals are read up front so every run tracks the same set; // `open` both gates and subscribes. effect(() => { const open = this.open.get(); const query = this.query.get(); const active = this.activeIndex.get(); // Re-filtering the list while the popover is still fading out is the // visible "flash on close": committing or reverting clears the query, // and the rows collapse or re-expand mid-transition. While a // self-managed panel is closed nothing is written: the rows keep their // last filter state and highlight through the exit transition, and // reopening re-ranks in the same microtask drain as showPopover(), so // both land before one paint. The inline variant has no panel to fade, // and a panel owned by a native surface (command) can become visible // before its `toggle` task mirrors `open`, so neither is gated. if (!open && this.managesPanel && !this.#inlinePanel()) { input.removeAttribute("aria-activedescendant"); return; } const { items, ranked, visible } = this.#rank(query); const sort = this.sortByScore; for (const verdict of ranked) { const item = items[verdict.index]; item.hidden = verdict.hidden; if (sort) item.style.order = String(-Math.round(verdict.score * 1000)); else if (item.style.order) item.style.removeProperty("order"); } visible.forEach((item, index) => { item.id ||= `ui-${prefix}-item-${++typeaheadIdCounter}`; if (index === active) item.setAttribute("data-highlighted", ""); else item.removeAttribute("data-highlighted"); }); const highlighted = active >= 0 ? visible[active] : undefined; if (highlighted) { input.setAttribute("aria-activedescendant", highlighted.id); highlighted.scrollIntoView({ block: "nearest" }); } else { input.removeAttribute("aria-activedescendant"); } }, { signal }); } /** * @description The panel's items, in DOM order. * * @returns Every `<prefix>-item` element in the panel. */ items() { const panel = this.panel; if (!panel) return []; return Array.from(panel.querySelectorAll(`[data-slot~="${this.slotPrefix}-item"]`)).filter((node) => node instanceof HTMLElement); } /** * @description The visible items in visual order: score order when * `data-sort="score"`, DOM order otherwise, so arrow keys always follow * what the user sees. * * @param items - All items, DOM order. * @param ranked - The ranker's verdicts for those items. * @returns Visible items in visual order. */ visibleItems(items, ranked) { const visible = ranked.filter((verdict) => !verdict.hidden); if (this.sortByScore) visible.sort((a, b) => b.score - a.score || a.index - b.index); return visible.map((verdict) => items[verdict.index]); } /** * @description Ranks every item against a query without touching the DOM. * Keyboard navigation must not read `item.hidden`: the output effect above * deliberately leaves those flags stale while the panel is closed, so a fresh * ranking is the only trustworthy filter state. * * @param query - What the user typed. * @returns The items in DOM order, their verdicts, and the visible subset in * visual order. * @private */ #rank(query) { const items = this.items(); const ranked = rankItems(query, items.map((item) => ({ value: this.itemValue(item), keywords: (item.getAttribute("data-keywords") ?? "").split(/\s+/).filter(Boolean), }))); return { items, ranked, visible: this.visibleItems(items, ranked) }; } /** * @description The text an item matches and commits with. The text-content * fallback excludes `<kbd>` shortcut hints, which are presentation, not * value ("Go to docs ⇧⌘D" matches and announces as "Go to docs"). * * @param item - The item element. * @returns `data-value` when present, trimmed hint-free text otherwise. */ itemValue(item) { const explicit = item.getAttribute("data-value"); if (explicit !== null) return explicit; if (!item.querySelector("kbd, ui-kbd-group")) return item.textContent?.trim() ?? ""; const clone = item.cloneNode(true); for (const hint of clone.querySelectorAll("kbd, ui-kbd-group")) hint.remove(); return clone.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed. Autocomplete and command * empty the field; combobox overrides this, because its input carries a * committed display value rather than the filter. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = ""; this.query.set(""); } /** * @description Minimum query length before the panel opens (`data-min-length`). * * @returns The threshold, 0 by default. * @private */ #minLength() { const raw = Number(this.getAttribute("data-min-length")); return Number.isFinite(raw) && raw > 0 ? raw : 0; } /** * @description Whether the panel is the inline variant: rendered in flow * without `[popover]`, and therefore always open. * * @returns True for an inline panel. * @private */ #inlinePanel() { return this.managesPanel && this.panel !== null && !this.panel.hasAttribute("popover"); } /** * @description Whether the panel popover is currently shown (polyfill-aware). * * @returns True when open. * @private */ #panelOpen() { return this.panel?.matches(":popover-open, .\\:popover-open") ?? false; } /** * @description Keyboard contract on the search input: arrows/Home/End move * the highlight, Enter commits it, Escape closes then clears (only when the * element manages its own panel: native surfaces own Escape themselves). * * @param event - The keydown event. * @private */ #onKeydown(event) { const input = this.searchInput; if (!input) return; if (event.key === "Escape" && this.managesPanel) { // The inline variant has no panel to close: Escape only clears if (this.open.get() && !this.#inlinePanel()) { event.preventDefault(); this.open.set(false); } else if (input.value) { event.preventDefault(); this.clearQuery(); } return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { // Home/End belong to the text caret unless an item is already active if ((event.key === "Home" || event.key === "End") && this.activeIndex.get() < 0) return; event.preventDefault(); if (this.managesPanel && !this.open.get()) this.open.set(true); const count = this.#rank(this.query.get()).visible.length; this.activeIndex.set(nextActiveIndex(this.activeIndex.get(), event.key, count)); return; } if (event.key === "Enter") { const active = this.activeIndex.get(); const item = active >= 0 ? this.#rank(this.query.get()).visible[active] : undefined; if (item) { event.preventDefault(); this.commit(item, "keyboard"); } } }}export { TypeaheadElement, rankItems, nextActiveIndex };// primitives/combobox/combobox.js"use strict";/** * @fileoverview `<ui-combobox>`: an input restricted to a predefined list. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`). A relative of select: the panel filters as you type, * but free text can never submit. The form value lives in an authored hidden * input (`data-slot="combobox-value"`), synced whenever the selection changes. * * The visible input carries a **display value, not the filter**. Committing * writes the option label into it and clears the query, so reopening shows * the full list with the committed row checked, matching `.ui-select`. * Opening selects that label so the first keystroke replaces it, and focus * alone never opens the panel (a click, the chevron, an arrow key or typing * does). On blur, stray text reverts to the committed label and cleared text * clears the selection. * * `data-variant="multiselect"` selects a set instead: committing toggles a row * and keeps the panel open, and every selected value renders as a removable * tag stamped inside the control. The tag markup is HTML, not script: author * a `<template data-slot="combobox-tag-template">` to control its classes and * structure, and this file fills in the label, the value, and the remove * button accessible name. Without one, the ui-badge default below applies. * Backspace on an empty input drops the last tag. Options carrying * `aria-selected="true"` are the single source of truth: the tags and the * hidden inputs are both derived from them in DOM order so nothing can drift. * The form submits repeated `name` pairs like `<select multiple>`: the authored * hidden input carries the first value, and stamped siblings carry the rest. * * Where a no-JS fallback matters, prefer `.ui-select` / `<ui-multiselect>`: * this control is inert without its script (the hidden input still submits a * server-set value, but no tags render). * * Attributes on the root: `data-variant="multiselect"`, `data-sort="score"`, * `data-min-length="<n>"`, `data-label-remove="Remove {label}"`. * Parts: `combobox-value` (hidden input), `combobox-control` (select-look * shell), `combobox-tag-template` (authored `<template>`), `combobox-tag` / * `combobox-tag-label` / `combobox-tag-remove` (cloned from it), * `combobox-trigger` (chevron), `combobox-panel`, `combobox-list`, * `combobox-item`, `combobox-group` / `combobox-group-label`, `combobox-empty`. */import { TypeaheadElement } from "../../base/typeahead.js";import { defineZazzElement } from "../../base/zazz-element.js";// --- Tag blueprint ---/** * The default tag markup, cloned once per selected value. Authors override it * wholesale with a `<template data-slot="combobox-tag-template">`, so restyling * a tag (other classes, an extra icon, a different component entirely) never * means editing this script. Kept as markup rather than createElement calls so * the default reads the same way the override is written. */const TAG_MARKUP = '<span class="ui-badge" data-slot="combobox-tag">' + '<span data-slot="combobox-tag-label"></span>' + '<button type="button" tabindex="-1" data-slot="combobox-tag-remove"></button>' + "</span>";let defaultTagTemplate = null;/** * @description Parses `TAG_MARKUP` once and shares the result. * * @returns The fallback tag template. * @private */function defaultTagBlueprint() { if (!defaultTagTemplate) { defaultTagTemplate = document.createElement("template"); defaultTagTemplate.innerHTML = TAG_MARKUP; } return defaultTagTemplate;}/** * @description Resolves a blur: free text never survives it. The multiselect * variant always empties the filter (its selection lives in the tags), the * single variant restores the committed label, and an emptied single-select * input clears the selection outright. * * @param typed - The input's current text. * @param committedLabel - The last committed label, empty for no selection. * @param multiselect - Whether the multiselect variant is active. * @returns The text to show and whether to clear the selection. */function resolveBlur(typed, committedLabel, multiselect) { if (multiselect) return { value: "", clear: false }; if (typed === "") return { value: "", clear: true }; return { value: committedLabel, clear: false };}/** * @description Fills the remove-button label template (`data-label-remove`). * * @param template - Template with a `{label}` placeholder. * @param label - The tag's visible label. * @returns The accessible name for that tag's remove button. */function resolveRemoveLabel(template, label) { return template.replace("{label}", label);}// --- Element ---class UiCombobox extends TypeaheadElement { slotPrefix = "combobox"; /** The input holds a committed label, so focus alone must not open the list. */ openOnFocus = false; #committedLabel = ""; #placeholder = ""; #defaultValues = []; #serialized = ""; setup(signal) { super.setup(signal); const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); this.#placeholder = input.getAttribute("placeholder") ?? ""; if (multiselect) { this.querySelector('[data-slot~="combobox-list"]')?.setAttribute("aria-multiselectable", "true"); } // Adopt a server-rendered value no row claims yet, so `value` alone is // enough markup to seed the selection, ensuring the sync below never // silently discards it const hidden = this.#valueInput(); if (hidden !== null && hidden.value !== "" && this.#selectedItems().length === 0) { const match = this.items().find((item) => this.#itemFormValue(item) === hidden.value); match?.setAttribute("aria-selected", "true"); } // A multi-select listbox announces every row state, not just the picked ones if (multiselect) { for (const item of this.items()) { if (item.getAttribute("aria-selected") !== "true") { item.setAttribute("aria-selected", "false"); } } } // Attribute state has no defaultValue: remember the markup selection so // a form reset can restore it this.#defaultValues = this.#selectedItems().map((item) => this.#itemFormValue(item)); this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.#syncSelection(false); const control = input.closest('[data-slot~="combobox-control"]'); // A select-look control is one big hit target input.addEventListener("click", () => this.#openPanel(), { signal }); control?.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element) || event.target === input) return; // Nothing in the shell may steal focus from the input: the chevron and // the tag remove buttons would otherwise blur it and close the panel event.preventDefault(); input.focus(); if (!event.target.closest('[data-slot~="combobox-trigger"], [data-slot~="combobox-tag"]')) { this.#openPanel(); } }, { signal }); // Tag removal is delegated: the tags are re-stamped on every change control?.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const remove = event.target.closest('[data-slot~="combobox-tag-remove"]'); if (!remove) return; // A template that forgets type="button" would otherwise submit the form event.preventDefault(); const value = remove.closest('[data-slot~="combobox-tag"]')?.getAttribute("data-value"); this.#deselect(this.items().find((item) => this.#itemFormValue(item) === value)); input.focus(); }, { signal }); // Chevron toggles the full, unfiltered list const trigger = this.querySelector('[data-slot~="combobox-trigger"]'); if (trigger instanceof HTMLElement) { trigger.addEventListener("click", () => { if (this.open.get()) this.open.set(false); else this.#openPanel(); }, { signal }); } // Backspace on an empty filter drops the last tag if (multiselect) { input.addEventListener("keydown", (event) => { if (event.key !== "Backspace" || input.value !== "") return; const last = this.#selectedItems().at(-1); if (!last) return; event.preventDefault(); this.#deselect(last); }, { signal }); } // No free text: on leaving, revert stray text or clear the selection this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; const outcome = resolveBlur(input.value, this.#committedLabel, this.#multiselect()); input.value = outcome.value; // Safe mid-close: the gated ranking effect ignores query writes while // the panel is fading out this.query.set(""); if (outcome.clear) this.#clearSelection(); }, { signal }); // A form reset restores the selection the markup shipped with (reset // applies after the event) (hidden ?? input).form?.addEventListener("reset", () => queueMicrotask(() => this.#restoreDefaults()), { signal }); } teardown() { for (const node of this.querySelectorAll('[data-slot~="combobox-tag"], [data-combobox-stamped]')) { node.remove(); } if (this.searchInput) this.searchInput.placeholder = this.#placeholder; } /** * @description Combobox items match against what the user sees (the label) * not the machine `data-value`. * * @param item - The item element. * @returns The trimmed visible label. */ itemValue(item) { return item.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed restores the committed * label (single) or drops the filter text (multiselect), avoiding a half-typed * value that a later blur would read as a cleared selection. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = this.#multiselect() ? "" : this.#committedLabel; this.query.set(""); } /** * @description Committing shows the item's label, stores its `data-value` in * the hidden input, and moves `aria-selected`. The multiselect variant * toggles the row instead and keeps the panel open so picking can continue. * * @param item - The committed option. */ commit(item, _source) { const input = this.searchInput; if (!input) return; if (this.#multiselect()) { item.setAttribute("aria-selected", String(item.getAttribute("aria-selected") !== "true")); input.value = ""; this.query.set(""); // Follow the row through the re-widened list so a second Enter toggles it // back: with an empty query every item is visible, and visual order is // DOM order (score ranking is stable at equal scores) this.activeIndex.set(this.items().indexOf(item)); this.#syncSelection(true); input.focus(); return; } this.#committedLabel = this.itemValue(item); input.value = this.#committedLabel; // The label is display text, not a filter: clearing the query is what // makes the reopened panel show the full list with this row checked this.query.set(""); for (const other of this.items()) other.removeAttribute("aria-selected"); item.setAttribute("aria-selected", "true"); this.#syncSelection(true); input.focus(); // The inline variant has no popover to close, and closing it would gate its // filtering off for good. It also never reopens, so commit is where it // selects the label (the popover variants do that in #openPanel()). if (this.panel?.hasAttribute("popover")) this.open.set(false); else input.select(); } /** * @description Whether the multiselect variant is active. * * @returns True for `data-variant="multiselect"`. * @private */ #multiselect() { return this.getAttribute("data-variant") === "multiselect"; } /** * @description Opens the panel the way a select does: the full list, the * committed row highlighted, and its label selected so the first keystroke * replaces it. `activeIndex` is written here rather than from an effect * because effects are output adapters, and writes from inside one can be dropped * until the next notification. * @private */ #openPanel() { const input = this.searchInput; if (!input || this.open.get()) return; if (this.#multiselect()) { input.value = ""; this.activeIndex.set(-1); } else { input.value = this.#committedLabel; const selected = this.#selectedItems()[0]; this.activeIndex.set(selected ? this.items().indexOf(selected) : -1); if (input.value !== "") input.select(); } this.query.set(""); this.open.set(true); } /** * @description The selection: items carrying `aria-selected="true"`, in DOM * order. The single source of truth for tags and form values alike. * * @returns The selected items. * @private */ #selectedItems() { return this.items().filter((item) => item.getAttribute("aria-selected") === "true"); } /** * @description The single-select committed label. * * @returns The selected item's label, or undefined when nothing is selected. * @private */ #selectedLabel() { const selected = this.#selectedItems()[0]; return selected ? this.itemValue(selected) : undefined; } /** * @description An item's machine value. * * @param item - The item element. * @returns `data-value` when present, the visible label otherwise. * @private */ #itemFormValue(item) { return item.getAttribute("data-value") ?? this.itemValue(item); } /** * @description Drops one item from the selection. * * @param item - The item to deselect; a no-op when undefined. * @private */ #deselect(item) { if (!item) return; item.setAttribute("aria-selected", "false"); this.#syncSelection(true); } /** * @description The authored hidden input carrying the form value (never one * of the stamped siblings). * * @returns The hidden input, or null when the author omitted it. * @private */ #valueInput() { const hidden = this.querySelector('[data-slot~="combobox-value"]:not([data-combobox-stamped])'); return hidden instanceof HTMLInputElement ? hidden : null; } /** * @description Mirrors the selection into the two things derived from it: the * tags and the hidden inputs. The authored input carries the first value and * stamped siblings carry the rest under the same `name`, so a multiselection * submits as repeated pairs exactly like `<select multiple>`. DOM * construction stays imperative: the DOM is the source of truth here, not a * signal. * * @param notify - Whether to dispatch `change` (skipped while seeding). * @private */ #syncSelection(notify) { const input = this.searchInput; const values = this.#selectedItems().map((item) => this.#itemFormValue(item)); const serialized = JSON.stringify(values); const changed = serialized !== this.#serialized; this.#serialized = serialized; if (input && this.#multiselect()) this.#renderTags(input); const hidden = this.#valueInput(); if (!hidden) return; hidden.value = values[0] ?? ""; for (const stale of this.querySelectorAll("[data-combobox-stamped]")) stale.remove(); let anchor = hidden; for (const value of values.slice(1)) { const extra = document.createElement("input"); extra.type = "hidden"; extra.setAttribute("data-slot", "combobox-value"); extra.setAttribute("data-combobox-stamped", ""); if (hidden.name) extra.name = hidden.name; extra.value = value; anchor.after(extra); anchor = extra; } if (notify && changed) hidden.dispatchEvent(new Event("change", { bubbles: true })); } /** * @description The element cloned per selected value: the first child of an * authored `<template data-slot="combobox-tag-template">` when present, the * default ui-badge otherwise. A template's content lives in a separate * fragment, so a blueprint carrying `data-slot="combobox-tag"` is invisible to * the stale-tag sweep and can never be mistaken for a rendered tag. * * @returns The blueprint element, or null when an authored template is empty. * @private */ #tagBlueprint() { const authored = this.querySelector('[data-slot~="combobox-tag-template"]'); const template = authored instanceof HTMLTemplateElement ? authored : defaultTagBlueprint(); const root = template.content.firstElementChild; return root instanceof HTMLElement ? root : null; } /** * @description Re-stamps the selection tags inside the control, in DOM order. * Rebuilt wholesale rather than diffed: the tags are pure output, nothing * focusable ever lands inside one, and removal is delegated from the control. * * @param input - The search input the tags render before. * @private */ #renderTags(input) { const control = input.closest('[data-slot~="combobox-control"]'); if (!control) return; for (const stale of control.querySelectorAll('[data-slot~="combobox-tag"]')) stale.remove(); const blueprint = this.#tagBlueprint(); if (!blueprint) return; const removeLabel = this.getAttribute("data-label-remove") ?? "Remove {label}"; const tags = this.#selectedItems().map((item) => { const label = this.itemValue(item); const tag = blueprint.cloneNode(true); tag.setAttribute("data-value", this.#itemFormValue(item)); // The slot is the contract every other moving part keys off (the CSS, the // stale sweep above, and tag removal), so add the token if the template // left it out rather than stamping an orphan if (!tag.matches('[data-slot~="combobox-tag"]')) { const slots = tag.getAttribute("data-slot"); tag.setAttribute("data-slot", slots ? `${slots} combobox-tag` : "combobox-tag"); } // The label wants its own box: text-overflow ignores a flex container's // own text, but a template without one still gets its text const text = tag.querySelector('[data-slot~="combobox-tag-label"]'); if (text) text.textContent = label; else tag.prepend(document.createTextNode(label)); tag .querySelector('[data-slot~="combobox-tag-remove"]') ?.setAttribute("aria-label", resolveRemoveLabel(removeLabel, label)); return tag; }); input.before(...tags); input.placeholder = tags.length > 0 ? "" : this.#placeholder; } /** * @description Empties the committed state: label, hidden value, tags, and * `aria-selected` all clear together. * @private */ #clearSelection() { this.#committedLabel = ""; const multiselect = this.#multiselect(); for (const item of this.items()) { if (multiselect) item.setAttribute("aria-selected", "false"); else item.removeAttribute("aria-selected"); } this.#syncSelection(true); } /** * @description Restores the selection the markup shipped with (form reset). * @private */ #restoreDefaults() { const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); for (const item of this.items()) { const selected = this.#defaultValues.includes(this.#itemFormValue(item)); if (multiselect) item.setAttribute("aria-selected", String(selected)); else if (selected) item.setAttribute("aria-selected", "true"); else item.removeAttribute("aria-selected"); } this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.query.set(""); this.#syncSelection(true); }}defineZazzElement("ui-combobox", UiCombobox);export { UiCombobox, resolveBlur, resolveRemoveLabel };Accessibility
The container deliberately omits role="menubar". That role requires a single tab stop with roving arrow-key focus across triggers, which a static composition does not provide. Instead, every trigger remains natively tabbable, and each open menu provides arrow-key navigation through the menu script. Provide an aria-label describing the group on the container.
API
| Attribute | Target | Values |
|---|---|---|
| (container) | any element | Utility recipe, no component class |
popovertarget + interestfor | Each trigger | Same panel id: click + hover glide |