Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Command

A command palette: search and quick actions behind a shortcut.

Installation

pnpm dlx zazz-ui add command
npx zazz-ui add command
yarn dlx zazz-ui add command
bunx zazz-ui add command

Also adds fields, kbd, button, popover, dialog, menu, select, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Default

Actions

No commands found.

↑↓ navigate ↵ select esc close

Opened by a command

The command item is a plain invoker button (no custom wiring).

<ui-command
  data-command-hotkey="mod+k"
  style="
    --w: 100%;
    --max-w: 96;
  "
>
  <button
    data-ui="input"
    type="button"
    popovertarget="command-example-1"
    data-command-slot="open"
    style="
      --display: inline-flex;
      --items: center;
      --justify: space-between;
      --pr: 1.5;
      --gap: 2;
      --w: 100%;
      --w--md: 64;
    "
  >
    <span style="--text: var(--color-muted-foreground)">Search...</span>
    <ui-kbd-group><kbd>⌘</kbd><kbd>K</kbd></ui-kbd-group>
  </button>
  <div
    id="command-example-1"
    data-command-slot="panel"
    data-popover-side="bottom"
    data-popover-align="start"
    popover="auto"
  >
    <header data-command-slot="header">
      <input
        data-command-slot="input"
        name="command"
        type="text"
        placeholder="Type a command or search..."
        role="combobox"
        aria-expanded="false"
        aria-autocomplete="list"
        autocomplete="off"
        aria-label="Search commands"
      />
    </header>
    <div role="listbox" data-command-slot="list" aria-label="Commands">
      <div role="group" data-command-slot="group" aria-labelledby="command-example-1-nav">
        <span
          data-command-slot="group-label"
          id="command-example-1-nav"
          style="
            --display: inline-flex;
            --font-size: var(--font-size-eyebrow);
            --font-weight: strong;
            --font-weight: var(--font-weight-eyebrow);
            --leading: var(--leading-eyebrow);
            --tracking: var(--tracking-eyebrow);
            --text-transform: uppercase;
            --text: var(--color-muted-foreground);
          "
          >Navigation</span
        >
        <a
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          href="#docs"
          tabindex="-1"
          data-command-keywords="documentation guides"
          data-command-hotkey="mod+shift+d"
          style="--justify: start"
        >
          Go to docs
          <ui-kbd-group><kbd>⇧</kbd><kbd>⌘</kbd><kbd>D</kbd></ui-kbd-group>
        </a>
        <a
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          href="#settings"
          tabindex="-1"
          data-command-keywords="preferences options"
          data-command-hotkey="mod+shift+s"
          style="--justify: start"
        >
          Go to settings
          <ui-kbd-group><kbd>⇧</kbd><kbd>⌘</kbd><kbd>S</kbd></ui-kbd-group>
        </a>
      </div>
      <div role="group" data-command-slot="group" aria-labelledby="command-example-1-actions">
        <span
          data-command-slot="group-label"
          id="command-example-1-actions"
          style="
            --display: inline-flex;
            --font-size: var(--font-size-eyebrow);
            --font-weight: strong;
            --font-weight: var(--font-weight-eyebrow);
            --leading: var(--leading-eyebrow);
            --tracking: var(--tracking-eyebrow);
            --text-transform: uppercase;
            --text: var(--color-muted-foreground);
          "
          >Actions</span
        >
        <button
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          type="button"
          command="show-modal"
          commandfor="command-example-1-dialog"
          tabindex="-1"
          data-command-keywords="modal open"
          style="--justify: start"
        >
          Open example dialog
        </button>
      </div>
    </div>
    <p
      data-command-slot="empty"
      style="
        --font-size: var(--font-size-sm);
        --leading: var(--leading-sm);
        --tracking: var(--tracking-sm);
        --text: var(--color-muted-foreground);
      "
    >
      No commands found.
    </p>
    <footer data-command-slot="footer">
      <span
        ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span
      >
      <span><kbd>↵</kbd> select</span>
      <span><kbd>esc</kbd> close</span>
    </footer>
  </div>
</ui-command>

<dialog id="command-example-1-dialog" data-ui="dialog" closedby="any">
  <div data-dialog-slot="content">
    <header data-dialog-slot="header">
      <h2
        style="
          --font-size: var(--font-size-lg);
          --font-weight: strong;
          --font-family: var(--font-family-heading);
          --leading: var(--leading-lg);
          --tracking: var(--tracking-lg);
        "
      >
        Opened by a command
      </h2>
    </header>
    <div data-dialog-slot="body">
      <p>The command item is a plain invoker button (no custom wiring).</p>
    </div>
  </div>
  <footer data-dialog-slot="footer">
    <button data-ui="button" type="button" commandfor="command-example-1-dialog" command="close">
      Close
    </button>
  </footer>
</dialog>
/**
 * command.css: Command (ui-command | [data-ui~="command"], [data-command-slot~="panel"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, popover.css, button.css, fields.css,
 *             dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*)
 * @uses       popovertarget + [popover="auto"]: dropdown surface, native light dismiss
 * @uses       <dialog data-ui="dialog"> + command="show-modal": focused surface
 * @uses       anchor-name, anchor-scope, position-anchor: tether the popover form
 * @uses       :has(): group + empty-state visibility without JavaScript
 * @tokens     --ui-command-* (@layer variables); items alias --ui-option-*
 * @see        command.ts: ranking, hotkeys, and activation behavior
 */

@layer variables {
  :root {
    --ui-command-min-w: calc(var(--spacing) * 96);
    --ui-command-max-h: calc(var(--spacing) * 96);
    --ui-command-option-gap: var(--ui-field-option-gap);
    --ui-command-input-p: calc(var(--spacing) * 3);
    --ui-command-input-border: 1px solid var(--color-border);
    --ui-command-group-label-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2)
      var(--spacing);
    --ui-command-footer-p: calc(var(--spacing) * 2);
    --ui-command-footer-border: 1px solid var(--color-border);
    --ui-command-shadow: var(--ui-menu-shadow);
  }
}

@layer ui.components {
  /* ===========================================================================
  COMMAND: palette panel (popover or dialog) with the input inside
  - The popover form rides popover.css's surface + placement; the dialog form
    rides dialog.css. This file shapes the interior: borderless input over a
    hairline, scrollable list, eyebrow group labels, hint footer.
  =========================================================================== */

  :where(ui-command, [data-ui~="command"]) {
    anchor-scope: --ui-command-trigger;
    display: inline-flex;
  }

  :where(ui-command, [data-ui~="command"]) > :where([popovertarget], [command]) {
    anchor-name: --ui-command-trigger;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"] {
    /* flex-direction stays ungated so the close fade (display held by the
       allow-discrete transition) keeps the column layout to the last frame */
    flex-direction: column;
    --_min-w-len: var(--_min-w-resolved, var(--ui-command-min-w));
    --_min-w-num: var(--_min-w-resolved, var(--ui-command-min-w));
    min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
    box-shadow: var(--ui-command-shadow);
  }

  /* Only display is gated; unconditional it would defeat the UA's
     display: none on closed popovers and dialogs */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:where(:popover-open, [open]) {
    display: flex;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where([popover]) {
    position-anchor: --ui-command-trigger;
    padding: 0;
  }

  /* Dialog form: the palette owns its interior, edge to edge. A palette is
     asked for, so it skips the dialog's backdrop-first delay and opens at once. */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where(dialog) {
    --ui-dialog-w: min(var(--layout-sm), 100% - var(--gutters) * 2);
    --ui-dialog-transition-delay--end: 0ms;
    padding: 0;
  }

  /* Search input: borderless over a hairline; the panel is the surface */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="header"] {
    display: flex;
    align-items: center;
    border-block-end: var(--ui-command-input-border);
  }

  /* A close button beside the input: the dialog form's way out on touch, where
     there is no Esc and the backdrop may be a sliver */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="header"]
    > :where([data-ui~="button"]) {
    flex: none;
    margin-inline-end: var(--spacing);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="input"] {
    flex: 1;
    min-inline-size: 0;
    padding: var(--ui-command-input-p);
    font: inherit;
    font-size: var(--ui-field-font-size);
    color: inherit;
    background: none;
    border: none;
    outline: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="list"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
    max-block-size: var(--ui-command-max-h);
    padding: var(--ui-popover-p);
    margin: 0;
    overflow-y: auto;
    overflow-x: clip;
    list-style: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group-label"] {
    padding: var(--ui-command-group-label-padding);
    /* Score ranking writes order -1000..0 on sibling items; keep the label first */
    order: -1001;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="item"] {
    cursor: pointer;
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"][data-command-state~="highlighted"] {
    --_button-bg: var(--ui-button-bg--hover);
    --_button-text: var(--ui-button-text--hover);
  }

  /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end;
     spacing between keys inside a run is the group's --ui-kbd-group-gap. */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"]
    > :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]) {
    margin-inline-start: auto;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="footer"] {
    display: flex;
    align-items: center;
    gap: calc(var(--spacing) * 2);
    padding: var(--ui-command-footer-p);
    border-block-start: var(--ui-command-footer-border);
    color: var(--color-muted-foreground);
    font-size: var(--font-size-xs);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="group"]:not(:has([data-command-slot~="item"]:not([hidden]))) {
    display: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="empty"] {
    display: none;
    padding: calc(var(--spacing) * 2);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:not(:has([data-command-slot~="item"]:not([hidden])))
    [data-command-slot~="empty"] {
    display: block;
  }
}
// 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-menu-slot~="popover"]` panel) with
 * arrow-key navigation. The attribute form `[data-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 data-ui="button" type="button" popovertarget="m1">Open</button>
 *   <div id="m1" data-menu-slot="popover" popover="auto">
 *     <menu>
 *       <li><a href="/docs" data-ui="button" data-button-variant="ghost" style="--justify: start">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-menu-slot~="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, [data-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, [data-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");
        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 };

// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

// primitives/command/command.js
"use strict";
/**
 * @fileoverview `<ui-command>`: a command menu for search and quick actions.
 * @description Light-DOM custom element on the shared typeahead engine
 * (`base/typeahead.ts`). The panel (a `popover="auto"`
 * dropdown or a `<dialog data-ui="dialog">`) contains the search input;
 * items rank by match score and the best match auto-highlights.
 *
 * Actions are the platform's own vocabulary: navigation items are real
 * `<a href>` links, and action items are `<button command commandfor>` invoker
 * buttons (including custom `--commands`). Enter activates the highlighted
 * item exactly as a click would; every activation also dispatches a bubbling
 * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root.
 * Without JavaScript the trigger still opens the panel natively and every
 * item still works: only filtering, highlight, and hotkeys are lost.
 *
 * Attributes:
 * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`.
 * - `data-command-hotkey` (item): global accelerator that activates the item, active
 *   while the element is connected (even with the panel closed).
 * - `data-command-stay-open` (item): keep the panel open after activation.
 * - `data-command-sort="document"` (root): opt out of score ranking (default: score).
 * - `data-command-filter="none"` (root): opt out of filtering for a list another
 *   source already filtered (a search index); items added later are highlighted
 *   and navigable like typed matches.
 *
 * Parts: `command-open` (trigger), `command-panel`, `command-header`,
 * `command-input`, `command-list`, `command-group` / `command-group-label`,
 * `command-item` (`data-command-value`, `data-command-keywords`), `command-kbd`,
 * `command-footer`, `command-empty`.
 *
 * For complex custom actions, see the example `command-actions.ts`: listen
 * for your `--command` on its target, or for `zazz:command-select` on the root.
 */
import { TypeaheadElement } from "../../base/typeahead.js";
import { bindHotkey } from "../../base/hotkeys.js";
import { defineZazzElement } from "../../base/zazz-element.js";
class UiCommand extends TypeaheadElement {
    slotPrefix = "command";
    managesPanel = false;
    autoHighlight = true;
    /** Ranks by score unless `data-command-sort="document"`. */
    get sortByScore() {
        return this.config("sort") !== "document";
    }
    setup(signal) {
        super.setup(signal);
        const input = this.searchInput;
        const panel = this.panel;
        if (!input || !panel)
            return;
        // Global toggle shortcut on the root
        const toggleSpec = this.getAttribute("data-command-hotkey");
        if (toggleSpec) {
            bindHotkey(toggleSpec, () => this.#togglePanel(), { signal });
        }
        // Per-item accelerators: global while connected, panel open or not
        for (const item of this.items()) {
            const spec = item.getAttribute("data-command-hotkey");
            if (spec) {
                bindHotkey(spec, () => this.#activate(item, true), { signal });
            }
        }
        // Reset the search whenever the panel closes, so it reopens fresh
        const reset = () => {
            input.value = "";
            this.query.set("");
            this.activeIndex.set(0);
        };
        if (panel instanceof HTMLDialogElement) {
            this.addEventListener("zazz:dialog-close", (event) => {
                if (event.target === panel)
                    reset();
            }, { signal });
        }
        else {
            panel.addEventListener("toggle", (event) => {
                if (event.newState === "closed")
                    reset();
            }, { signal });
        }
    }
    /**
     * @description A pointer commit has already run the item's native activation
     * (link navigation, invoker command); a keyboard commit runs it via
     * `click()`. Both announce `zazz:command-select` and close the panel unless
     * the item asks to stay open.
     *
     * @param item - The activated item.
     * @param source - How the commit happened.
     */
    commit(item, source) {
        // The synthetic click() below bubbles back here as a pointer commit:
        // one keystroke must announce once and close once.
        if (this.#clicking)
            return;
        this.#activate(item, source === "keyboard");
    }
    /** True while `#activate` runs an item's native activation via `click()`. */
    #clicking = false;
    /**
     * @description Runs one item's activation: optional synthetic click,
     * `zazz:command-select`, then close (unless `data-command-stay-open`).
     *
     * @param item - The item to activate.
     * @param click - Whether to run the native activation via `click()`.
     * @private
     */
    #activate(item, click) {
        this.dispatchEvent(new CustomEvent("zazz:command-select", {
            bubbles: true,
            detail: { item, value: this.itemValue(item) },
        }));
        if (click) {
            this.#clicking = true;
            try {
                item.click();
            }
            finally {
                this.#clicking = false;
            }
        }
        if (!item.hasAttribute("data-command-stay-open")) {
            this.#closePanel();
        }
    }
    /**
     * @description Opens or closes the panel, branching on its surface.
     * @private
     */
    #togglePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
            else
                panel.showModal();
        }
        else {
            panel.togglePopover();
        }
    }
    /**
     * @description Closes the panel, branching on its surface.
     * @private
     */
    #closePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
        }
        else if (panel.matches(":popover-open")) {
            panel.hidePopover();
        }
    }
}
defineZazzElement("ui-command", UiCommand);
export { UiCommand };

// primitives/command/command-actions.js
"use strict";
/**
 * @fileoverview Example: composing custom command actions for `<ui-command>`.
 * @description This file is a template, not part of the kit runtime: copy it
 * into your project and register your own actions. It is loaded only by the
 * `command-actions` docs example.
 *
 * Simple actions need no script at all: a navigation item is an `<a href>`,
 * and opening a dialog/popover is a `<button command commandfor>` invoker.
 * Reach for a script only when an action runs real logic. Two hooks exist:
 *
 * 1. **Custom invoker commands**: give the item `command="--your-action"`
 *    and `commandfor="<target id>"`, then listen for the `command` event on
 *    the target. The invokers polyfill re-dispatches commands in browsers
 *    with native support, so de-dupe per task (see below).
 * 2. **`zazz:command-select`**: every activation bubbles this CustomEvent
 *    from the `<ui-command>` root (`detail: { item, value }`); use it for
 *    palette-wide concerns like analytics.
 */
/**
 * @description Binds this page's custom `--command` handlers on the command
 * *target* (the `commandfor` element).
 */
function initCommandActions() {
    const themeTarget = document.getElementById("command-actions-target");
    if (!themeTarget)
        return;
    // The invokers polyfill can deliver the same command twice in one task, even
    // with native support.
    let lastEvent = null;
    themeTarget.addEventListener("command", (event) => {
        if (event === lastEvent)
            return;
        lastEvent = event;
        const command = event.command;
        switch (command) {
            case "--theme-toggle": {
                // A theme-dark / theme-light switch pins the scheme; neither follows the system
                const root = document.documentElement;
                const tokens = (root.dataset.ui ?? "").split(/\s+/).filter(Boolean);
                const pinned = tokens.find((t) => t === "theme-dark" || t === "theme-light");
                const dark = pinned
                    ? pinned === "theme-dark"
                    : matchMedia("(prefers-color-scheme: dark)").matches;
                root.dataset.ui = [
                    ...tokens.filter((t) => !t.startsWith("theme-")),
                    dark ? "theme-light" : "theme-dark",
                ].join(" ");
                break;
            }
            case "--copy-link":
                void navigator.clipboard?.writeText(window.location.href);
                break;
        }
    });
}
/**
 * @description Logs every command activation: swap for analytics, recents
 * tracking, or any cross-cutting concern.
 */
function initCommandSelectLogging() {
    document.addEventListener("zazz:command-select", (event) => {
        const detail = event.detail;
        console.info("[command] selected:", detail.value);
    });
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
    const init = () => {
        initCommandActions();
        initCommandSelectLogging();
    };
    if (document.readyState === "loading")
        document.addEventListener("DOMContentLoaded", init);
    else
        init();
}
export { initCommandActions, initCommandSelectLogging };

Dialog

No commands found.

↑↓ navigate ↵ select esc close
<ui-command
  data-command-hotkey="mod+shift+k"
  style="
    --w: 100%;
    --max-w: 96;
  "
>
  <button
    data-ui="button"
    type="button"
    data-button-variant="outline"
    command="show-modal"
    commandfor="command-example-dialog-1"
    data-command-slot="open"
    style="
      --justify: space-between;
      --w: 100%;
    "
  >
    <span style="--text: var(--color-muted-foreground)">Command palette...</span>
    <ui-kbd-group><kbd>⌘</kbd><kbd>⇧</kbd><kbd>K</kbd></ui-kbd-group>
  </button>
  <dialog id="command-example-dialog-1" data-ui="dialog" data-command-slot="panel" closedby="any">
    <header data-command-slot="header">
      <input
        data-command-slot="input"
        name="search"
        type="text"
        placeholder="Type a command or search..."
        role="combobox"
        aria-expanded="false"
        aria-autocomplete="list"
        autocomplete="off"
        aria-label="Search commands"
      />
      <button
        data-ui="button"
        data-button-variant="ghost"
        data-button-size="icon"
        type="button"
        command="close"
        commandfor="command-example-dialog-1"
        aria-label="Close"
      >
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
          <rect width="256" height="256" fill="none" />
          <line
            x1="200"
            y1="56"
            x2="56"
            y2="200"
            fill="none"
            stroke="currentColor"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          />
          <line
            x1="200"
            y1="200"
            x2="56"
            y2="56"
            fill="none"
            stroke="currentColor"
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="16"
          />
        </svg>
      </button>
    </header>
    <div role="listbox" data-command-slot="list" aria-label="Commands">
      <div role="group" data-command-slot="group" aria-labelledby="command-example-dialog-1-nav">
        <span
          data-command-slot="group-label"
          id="command-example-dialog-1-nav"
          style="
            --display: inline-flex;
            --font-size: var(--font-size-eyebrow);
            --font-weight: strong;
            --font-weight: var(--font-weight-eyebrow);
            --leading: var(--leading-eyebrow);
            --tracking: var(--tracking-eyebrow);
            --text-transform: uppercase;
            --text: var(--color-muted-foreground);
          "
          >Navigation</span
        >
        <a
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          href="#home"
          tabindex="-1"
          style="--justify: start"
        >
          Go home
        </a>
        <a
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          href="#projects"
          tabindex="-1"
          data-command-keywords="work portfolio"
          style="--justify: start"
        >
          Go to projects
        </a>
        <a
          role="option"
          data-command-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          href="#team"
          tabindex="-1"
          data-command-keywords="people members"
          style="--justify: start"
        >
          Go to team
        </a>
      </div>
    </div>
    <p data-command-slot="empty" style="--text: var(--color-muted-foreground)">
      No commands found.
    </p>
    <footer data-command-slot="footer">
      <span
        ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span
      >
      <span><kbd>↵</kbd> select</span>
      <span><kbd>esc</kbd> close</span>
    </footer>
  </dialog>
</ui-command>
/**
 * command.css: Command (ui-command | [data-ui~="command"], [data-command-slot~="panel"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, popover.css, button.css, fields.css,
 *             dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*)
 * @uses       popovertarget + [popover="auto"]: dropdown surface, native light dismiss
 * @uses       <dialog data-ui="dialog"> + command="show-modal": focused surface
 * @uses       anchor-name, anchor-scope, position-anchor: tether the popover form
 * @uses       :has(): group + empty-state visibility without JavaScript
 * @tokens     --ui-command-* (@layer variables); items alias --ui-option-*
 * @see        command.ts: ranking, hotkeys, and activation behavior
 */

@layer variables {
  :root {
    --ui-command-min-w: calc(var(--spacing) * 96);
    --ui-command-max-h: calc(var(--spacing) * 96);
    --ui-command-option-gap: var(--ui-field-option-gap);
    --ui-command-input-p: calc(var(--spacing) * 3);
    --ui-command-input-border: 1px solid var(--color-border);
    --ui-command-group-label-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2)
      var(--spacing);
    --ui-command-footer-p: calc(var(--spacing) * 2);
    --ui-command-footer-border: 1px solid var(--color-border);
    --ui-command-shadow: var(--ui-menu-shadow);
  }
}

@layer ui.components {
  /* ===========================================================================
  COMMAND: palette panel (popover or dialog) with the input inside
  - The popover form rides popover.css's surface + placement; the dialog form
    rides dialog.css. This file shapes the interior: borderless input over a
    hairline, scrollable list, eyebrow group labels, hint footer.
  =========================================================================== */

  :where(ui-command, [data-ui~="command"]) {
    anchor-scope: --ui-command-trigger;
    display: inline-flex;
  }

  :where(ui-command, [data-ui~="command"]) > :where([popovertarget], [command]) {
    anchor-name: --ui-command-trigger;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"] {
    /* flex-direction stays ungated so the close fade (display held by the
       allow-discrete transition) keeps the column layout to the last frame */
    flex-direction: column;
    --_min-w-len: var(--_min-w-resolved, var(--ui-command-min-w));
    --_min-w-num: var(--_min-w-resolved, var(--ui-command-min-w));
    min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
    box-shadow: var(--ui-command-shadow);
  }

  /* Only display is gated; unconditional it would defeat the UA's
     display: none on closed popovers and dialogs */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:where(:popover-open, [open]) {
    display: flex;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where([popover]) {
    position-anchor: --ui-command-trigger;
    padding: 0;
  }

  /* Dialog form: the palette owns its interior, edge to edge. A palette is
     asked for, so it skips the dialog's backdrop-first delay and opens at once. */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where(dialog) {
    --ui-dialog-w: min(var(--layout-sm), 100% - var(--gutters) * 2);
    --ui-dialog-transition-delay--end: 0ms;
    padding: 0;
  }

  /* Search input: borderless over a hairline; the panel is the surface */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="header"] {
    display: flex;
    align-items: center;
    border-block-end: var(--ui-command-input-border);
  }

  /* A close button beside the input: the dialog form's way out on touch, where
     there is no Esc and the backdrop may be a sliver */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="header"]
    > :where([data-ui~="button"]) {
    flex: none;
    margin-inline-end: var(--spacing);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="input"] {
    flex: 1;
    min-inline-size: 0;
    padding: var(--ui-command-input-p);
    font: inherit;
    font-size: var(--ui-field-font-size);
    color: inherit;
    background: none;
    border: none;
    outline: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="list"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
    max-block-size: var(--ui-command-max-h);
    padding: var(--ui-popover-p);
    margin: 0;
    overflow-y: auto;
    overflow-x: clip;
    list-style: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group-label"] {
    padding: var(--ui-command-group-label-padding);
    /* Score ranking writes order -1000..0 on sibling items; keep the label first */
    order: -1001;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="item"] {
    cursor: pointer;
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"][data-command-state~="highlighted"] {
    --_button-bg: var(--ui-button-bg--hover);
    --_button-text: var(--ui-button-text--hover);
  }

  /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end;
     spacing between keys inside a run is the group's --ui-kbd-group-gap. */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"]
    > :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]) {
    margin-inline-start: auto;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="footer"] {
    display: flex;
    align-items: center;
    gap: calc(var(--spacing) * 2);
    padding: var(--ui-command-footer-p);
    border-block-start: var(--ui-command-footer-border);
    color: var(--color-muted-foreground);
    font-size: var(--font-size-xs);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="group"]:not(:has([data-command-slot~="item"]:not([hidden]))) {
    display: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="empty"] {
    display: none;
    padding: calc(var(--spacing) * 2);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:not(:has([data-command-slot~="item"]:not([hidden])))
    [data-command-slot~="empty"] {
    display: block;
  }
}
// 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-menu-slot~="popover"]` panel) with
 * arrow-key navigation. The attribute form `[data-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 data-ui="button" type="button" popovertarget="m1">Open</button>
 *   <div id="m1" data-menu-slot="popover" popover="auto">
 *     <menu>
 *       <li><a href="/docs" data-ui="button" data-button-variant="ghost" style="--justify: start">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-menu-slot~="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, [data-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, [data-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");
        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 };

// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

// primitives/command/command.js
"use strict";
/**
 * @fileoverview `<ui-command>`: a command menu for search and quick actions.
 * @description Light-DOM custom element on the shared typeahead engine
 * (`base/typeahead.ts`). The panel (a `popover="auto"`
 * dropdown or a `<dialog data-ui="dialog">`) contains the search input;
 * items rank by match score and the best match auto-highlights.
 *
 * Actions are the platform's own vocabulary: navigation items are real
 * `<a href>` links, and action items are `<button command commandfor>` invoker
 * buttons (including custom `--commands`). Enter activates the highlighted
 * item exactly as a click would; every activation also dispatches a bubbling
 * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root.
 * Without JavaScript the trigger still opens the panel natively and every
 * item still works: only filtering, highlight, and hotkeys are lost.
 *
 * Attributes:
 * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`.
 * - `data-command-hotkey` (item): global accelerator that activates the item, active
 *   while the element is connected (even with the panel closed).
 * - `data-command-stay-open` (item): keep the panel open after activation.
 * - `data-command-sort="document"` (root): opt out of score ranking (default: score).
 * - `data-command-filter="none"` (root): opt out of filtering for a list another
 *   source already filtered (a search index); items added later are highlighted
 *   and navigable like typed matches.
 *
 * Parts: `command-open` (trigger), `command-panel`, `command-header`,
 * `command-input`, `command-list`, `command-group` / `command-group-label`,
 * `command-item` (`data-command-value`, `data-command-keywords`), `command-kbd`,
 * `command-footer`, `command-empty`.
 *
 * For complex custom actions, see the example `command-actions.ts`: listen
 * for your `--command` on its target, or for `zazz:command-select` on the root.
 */
import { TypeaheadElement } from "../../base/typeahead.js";
import { bindHotkey } from "../../base/hotkeys.js";
import { defineZazzElement } from "../../base/zazz-element.js";
class UiCommand extends TypeaheadElement {
    slotPrefix = "command";
    managesPanel = false;
    autoHighlight = true;
    /** Ranks by score unless `data-command-sort="document"`. */
    get sortByScore() {
        return this.config("sort") !== "document";
    }
    setup(signal) {
        super.setup(signal);
        const input = this.searchInput;
        const panel = this.panel;
        if (!input || !panel)
            return;
        // Global toggle shortcut on the root
        const toggleSpec = this.getAttribute("data-command-hotkey");
        if (toggleSpec) {
            bindHotkey(toggleSpec, () => this.#togglePanel(), { signal });
        }
        // Per-item accelerators: global while connected, panel open or not
        for (const item of this.items()) {
            const spec = item.getAttribute("data-command-hotkey");
            if (spec) {
                bindHotkey(spec, () => this.#activate(item, true), { signal });
            }
        }
        // Reset the search whenever the panel closes, so it reopens fresh
        const reset = () => {
            input.value = "";
            this.query.set("");
            this.activeIndex.set(0);
        };
        if (panel instanceof HTMLDialogElement) {
            this.addEventListener("zazz:dialog-close", (event) => {
                if (event.target === panel)
                    reset();
            }, { signal });
        }
        else {
            panel.addEventListener("toggle", (event) => {
                if (event.newState === "closed")
                    reset();
            }, { signal });
        }
    }
    /**
     * @description A pointer commit has already run the item's native activation
     * (link navigation, invoker command); a keyboard commit runs it via
     * `click()`. Both announce `zazz:command-select` and close the panel unless
     * the item asks to stay open.
     *
     * @param item - The activated item.
     * @param source - How the commit happened.
     */
    commit(item, source) {
        // The synthetic click() below bubbles back here as a pointer commit:
        // one keystroke must announce once and close once.
        if (this.#clicking)
            return;
        this.#activate(item, source === "keyboard");
    }
    /** True while `#activate` runs an item's native activation via `click()`. */
    #clicking = false;
    /**
     * @description Runs one item's activation: optional synthetic click,
     * `zazz:command-select`, then close (unless `data-command-stay-open`).
     *
     * @param item - The item to activate.
     * @param click - Whether to run the native activation via `click()`.
     * @private
     */
    #activate(item, click) {
        this.dispatchEvent(new CustomEvent("zazz:command-select", {
            bubbles: true,
            detail: { item, value: this.itemValue(item) },
        }));
        if (click) {
            this.#clicking = true;
            try {
                item.click();
            }
            finally {
                this.#clicking = false;
            }
        }
        if (!item.hasAttribute("data-command-stay-open")) {
            this.#closePanel();
        }
    }
    /**
     * @description Opens or closes the panel, branching on its surface.
     * @private
     */
    #togglePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
            else
                panel.showModal();
        }
        else {
            panel.togglePopover();
        }
    }
    /**
     * @description Closes the panel, branching on its surface.
     * @private
     */
    #closePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
        }
        else if (panel.matches(":popover-open")) {
            panel.hidePopover();
        }
    }
}
defineZazzElement("ui-command", UiCommand);
export { UiCommand };

// primitives/command/command-actions.js
"use strict";
/**
 * @fileoverview Example: composing custom command actions for `<ui-command>`.
 * @description This file is a template, not part of the kit runtime: copy it
 * into your project and register your own actions. It is loaded only by the
 * `command-actions` docs example.
 *
 * Simple actions need no script at all: a navigation item is an `<a href>`,
 * and opening a dialog/popover is a `<button command commandfor>` invoker.
 * Reach for a script only when an action runs real logic. Two hooks exist:
 *
 * 1. **Custom invoker commands**: give the item `command="--your-action"`
 *    and `commandfor="<target id>"`, then listen for the `command` event on
 *    the target. The invokers polyfill re-dispatches commands in browsers
 *    with native support, so de-dupe per task (see below).
 * 2. **`zazz:command-select`**: every activation bubbles this CustomEvent
 *    from the `<ui-command>` root (`detail: { item, value }`); use it for
 *    palette-wide concerns like analytics.
 */
/**
 * @description Binds this page's custom `--command` handlers on the command
 * *target* (the `commandfor` element).
 */
function initCommandActions() {
    const themeTarget = document.getElementById("command-actions-target");
    if (!themeTarget)
        return;
    // The invokers polyfill can deliver the same command twice in one task, even
    // with native support.
    let lastEvent = null;
    themeTarget.addEventListener("command", (event) => {
        if (event === lastEvent)
            return;
        lastEvent = event;
        const command = event.command;
        switch (command) {
            case "--theme-toggle": {
                // A theme-dark / theme-light switch pins the scheme; neither follows the system
                const root = document.documentElement;
                const tokens = (root.dataset.ui ?? "").split(/\s+/).filter(Boolean);
                const pinned = tokens.find((t) => t === "theme-dark" || t === "theme-light");
                const dark = pinned
                    ? pinned === "theme-dark"
                    : matchMedia("(prefers-color-scheme: dark)").matches;
                root.dataset.ui = [
                    ...tokens.filter((t) => !t.startsWith("theme-")),
                    dark ? "theme-light" : "theme-dark",
                ].join(" ");
                break;
            }
            case "--copy-link":
                void navigator.clipboard?.writeText(window.location.href);
                break;
        }
    });
}
/**
 * @description Logs every command activation: swap for analytics, recents
 * tracking, or any cross-cutting concern.
 */
function initCommandSelectLogging() {
    document.addEventListener("zazz:command-select", (event) => {
        const detail = event.detail;
        console.info("[command] selected:", detail.value);
    });
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
    const init = () => {
        initCommandActions();
        initCommandSelectLogging();
    };
    if (document.readyState === "loading")
        document.addEventListener("DOMContentLoaded", init);
    else
        init();
}
export { initCommandActions, initCommandSelectLogging };

Actions

No commands found.

↑↓ navigate ↵ select
<!-- command-actions.js shows how to register custom actions: listen for your
     `--command` on its `commandfor` target, or for `zazz:command-select` on
     the root. -->
<ui-command
  data-command-hotkey="mod+j"
  id="command-actions-target"
  style="
    --w: 100%;
    --max-w: 96;
  "
>
  <button
    data-ui="button"
    type="button"
    data-button-variant="outline"
    popovertarget="command-example-actions-1"
    data-command-slot="open"
    style="
      --justify: space-between;
      --w: 100%;
    "
  >
    <span style="--text: var(--color-muted-foreground)">Quick actions...</span>
    <ui-kbd-group><kbd>⌘</kbd><kbd>J</kbd></ui-kbd-group>
  </button>
  <div
    id="command-example-actions-1"
    data-command-slot="panel"
    data-popover-side="bottom"
    data-popover-align="start"
    popover="auto"
  >
    <header data-command-slot="header">
      <input
        data-command-slot="input"
        name="action"
        type="text"
        placeholder="Type a command..."
        role="combobox"
        aria-expanded="false"
        aria-autocomplete="list"
        autocomplete="off"
        aria-label="Search commands"
      />
    </header>
    <div role="listbox" data-command-slot="list" aria-label="Commands">
      <button
        role="option"
        data-command-slot="item"
        data-ui="button"
        data-button-variant="ghost"
        type="button"
        command="--theme-toggle"
        commandfor="command-actions-target"
        tabindex="-1"
        data-command-keywords="dark light mode appearance"
        data-command-stay-open
        style="--justify: start"
      >
        Toggle theme
      </button>
      <button
        role="option"
        data-command-slot="item"
        data-ui="button"
        data-button-variant="ghost"
        type="button"
        command="--copy-link"
        commandfor="command-actions-target"
        tabindex="-1"
        data-command-keywords="share url clipboard"
        style="--justify: start"
      >
        Copy link
      </button>
      <button
        role="option"
        data-command-slot="item"
        data-ui="button"
        data-button-variant="ghost"
        type="button"
        command="--toast-success"
        commandfor="command-actions-toaster"
        data-toaster-title="Saved"
        data-toaster-description="Your changes are safe."
        tabindex="-1"
        data-command-keywords="notify notification"
        style="--justify: start"
      >
        Show a toast
      </button>
    </div>
    <p data-command-slot="empty" style="--text: var(--color-muted-foreground)">
      No commands found.
    </p>
    <footer data-command-slot="footer">
      <span
        ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span
      >
      <span><kbd>↵</kbd> select</span>
    </footer>
  </div>
</ui-command>

<ui-toaster id="command-actions-toaster" data-toaster-position="bottom-end"></ui-toaster>
/**
 * command.css: Command (ui-command | [data-ui~="command"], [data-command-slot~="panel"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, popover.css, button.css, fields.css,
 *             dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*)
 * @uses       popovertarget + [popover="auto"]: dropdown surface, native light dismiss
 * @uses       <dialog data-ui="dialog"> + command="show-modal": focused surface
 * @uses       anchor-name, anchor-scope, position-anchor: tether the popover form
 * @uses       :has(): group + empty-state visibility without JavaScript
 * @tokens     --ui-command-* (@layer variables); items alias --ui-option-*
 * @see        command.ts: ranking, hotkeys, and activation behavior
 */

@layer variables {
  :root {
    --ui-command-min-w: calc(var(--spacing) * 96);
    --ui-command-max-h: calc(var(--spacing) * 96);
    --ui-command-option-gap: var(--ui-field-option-gap);
    --ui-command-input-p: calc(var(--spacing) * 3);
    --ui-command-input-border: 1px solid var(--color-border);
    --ui-command-group-label-padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2)
      var(--spacing);
    --ui-command-footer-p: calc(var(--spacing) * 2);
    --ui-command-footer-border: 1px solid var(--color-border);
    --ui-command-shadow: var(--ui-menu-shadow);
  }
}

@layer ui.components {
  /* ===========================================================================
  COMMAND: palette panel (popover or dialog) with the input inside
  - The popover form rides popover.css's surface + placement; the dialog form
    rides dialog.css. This file shapes the interior: borderless input over a
    hairline, scrollable list, eyebrow group labels, hint footer.
  =========================================================================== */

  :where(ui-command, [data-ui~="command"]) {
    anchor-scope: --ui-command-trigger;
    display: inline-flex;
  }

  :where(ui-command, [data-ui~="command"]) > :where([popovertarget], [command]) {
    anchor-name: --ui-command-trigger;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"] {
    /* flex-direction stays ungated so the close fade (display held by the
       allow-discrete transition) keeps the column layout to the last frame */
    flex-direction: column;
    --_min-w-len: var(--_min-w-resolved, var(--ui-command-min-w));
    --_min-w-num: var(--_min-w-resolved, var(--ui-command-min-w));
    min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
    box-shadow: var(--ui-command-shadow);
  }

  /* Only display is gated; unconditional it would defeat the UA's
     display: none on closed popovers and dialogs */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:where(:popover-open, [open]) {
    display: flex;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where([popover]) {
    position-anchor: --ui-command-trigger;
    padding: 0;
  }

  /* Dialog form: the palette owns its interior, edge to edge. A palette is
     asked for, so it skips the dialog's backdrop-first delay and opens at once. */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="panel"]:where(dialog) {
    --ui-dialog-w: min(var(--layout-sm), 100% - var(--gutters) * 2);
    --ui-dialog-transition-delay--end: 0ms;
    padding: 0;
  }

  /* Search input: borderless over a hairline; the panel is the surface */
  :where(ui-command, [data-ui~="command"]) [data-command-slot~="header"] {
    display: flex;
    align-items: center;
    border-block-end: var(--ui-command-input-border);
  }

  /* A close button beside the input: the dialog form's way out on touch, where
     there is no Esc and the backdrop may be a sliver */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="header"]
    > :where([data-ui~="button"]) {
    flex: none;
    margin-inline-end: var(--spacing);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="input"] {
    flex: 1;
    min-inline-size: 0;
    padding: var(--ui-command-input-p);
    font: inherit;
    font-size: var(--ui-field-font-size);
    color: inherit;
    background: none;
    border: none;
    outline: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="list"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
    max-block-size: var(--ui-command-max-h);
    padding: var(--ui-popover-p);
    margin: 0;
    overflow-y: auto;
    overflow-x: clip;
    list-style: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group"] {
    display: flex;
    flex-direction: column;
    gap: var(--ui-command-option-gap);
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="group-label"] {
    padding: var(--ui-command-group-label-padding);
    /* Score ranking writes order -1000..0 on sibling items; keep the label first */
    order: -1001;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="item"] {
    cursor: pointer;
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"][data-command-state~="highlighted"] {
    --_button-bg: var(--ui-button-bg--hover);
    --_button-text: var(--ui-button-text--hover);
  }

  /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end;
     spacing between keys inside a run is the group's --ui-kbd-group-gap. */
  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="item"]
    > :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]) {
    margin-inline-start: auto;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="footer"] {
    display: flex;
    align-items: center;
    gap: calc(var(--spacing) * 2);
    padding: var(--ui-command-footer-p);
    border-block-start: var(--ui-command-footer-border);
    color: var(--color-muted-foreground);
    font-size: var(--font-size-xs);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="group"]:not(:has([data-command-slot~="item"]:not([hidden]))) {
    display: none;
  }

  :where(ui-command, [data-ui~="command"]) [data-command-slot~="empty"] {
    display: none;
    padding: calc(var(--spacing) * 2);
  }

  :where(ui-command, [data-ui~="command"])
    [data-command-slot~="panel"]:not(:has([data-command-slot~="item"]:not([hidden])))
    [data-command-slot~="empty"] {
    display: block;
  }
}
// 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-menu-slot~="popover"]` panel) with
 * arrow-key navigation. The attribute form `[data-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 data-ui="button" type="button" popovertarget="m1">Open</button>
 *   <div id="m1" data-menu-slot="popover" popover="auto">
 *     <menu>
 *       <li><a href="/docs" data-ui="button" data-button-variant="ghost" style="--justify: start">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-menu-slot~="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, [data-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, [data-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");
        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 };

// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

// primitives/command/command.js
"use strict";
/**
 * @fileoverview `<ui-command>`: a command menu for search and quick actions.
 * @description Light-DOM custom element on the shared typeahead engine
 * (`base/typeahead.ts`). The panel (a `popover="auto"`
 * dropdown or a `<dialog data-ui="dialog">`) contains the search input;
 * items rank by match score and the best match auto-highlights.
 *
 * Actions are the platform's own vocabulary: navigation items are real
 * `<a href>` links, and action items are `<button command commandfor>` invoker
 * buttons (including custom `--commands`). Enter activates the highlighted
 * item exactly as a click would; every activation also dispatches a bubbling
 * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root.
 * Without JavaScript the trigger still opens the panel natively and every
 * item still works: only filtering, highlight, and hotkeys are lost.
 *
 * Attributes:
 * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`.
 * - `data-command-hotkey` (item): global accelerator that activates the item, active
 *   while the element is connected (even with the panel closed).
 * - `data-command-stay-open` (item): keep the panel open after activation.
 * - `data-command-sort="document"` (root): opt out of score ranking (default: score).
 * - `data-command-filter="none"` (root): opt out of filtering for a list another
 *   source already filtered (a search index); items added later are highlighted
 *   and navigable like typed matches.
 *
 * Parts: `command-open` (trigger), `command-panel`, `command-header`,
 * `command-input`, `command-list`, `command-group` / `command-group-label`,
 * `command-item` (`data-command-value`, `data-command-keywords`), `command-kbd`,
 * `command-footer`, `command-empty`.
 *
 * For complex custom actions, see the example `command-actions.ts`: listen
 * for your `--command` on its target, or for `zazz:command-select` on the root.
 */
import { TypeaheadElement } from "../../base/typeahead.js";
import { bindHotkey } from "../../base/hotkeys.js";
import { defineZazzElement } from "../../base/zazz-element.js";
class UiCommand extends TypeaheadElement {
    slotPrefix = "command";
    managesPanel = false;
    autoHighlight = true;
    /** Ranks by score unless `data-command-sort="document"`. */
    get sortByScore() {
        return this.config("sort") !== "document";
    }
    setup(signal) {
        super.setup(signal);
        const input = this.searchInput;
        const panel = this.panel;
        if (!input || !panel)
            return;
        // Global toggle shortcut on the root
        const toggleSpec = this.getAttribute("data-command-hotkey");
        if (toggleSpec) {
            bindHotkey(toggleSpec, () => this.#togglePanel(), { signal });
        }
        // Per-item accelerators: global while connected, panel open or not
        for (const item of this.items()) {
            const spec = item.getAttribute("data-command-hotkey");
            if (spec) {
                bindHotkey(spec, () => this.#activate(item, true), { signal });
            }
        }
        // Reset the search whenever the panel closes, so it reopens fresh
        const reset = () => {
            input.value = "";
            this.query.set("");
            this.activeIndex.set(0);
        };
        if (panel instanceof HTMLDialogElement) {
            this.addEventListener("zazz:dialog-close", (event) => {
                if (event.target === panel)
                    reset();
            }, { signal });
        }
        else {
            panel.addEventListener("toggle", (event) => {
                if (event.newState === "closed")
                    reset();
            }, { signal });
        }
    }
    /**
     * @description A pointer commit has already run the item's native activation
     * (link navigation, invoker command); a keyboard commit runs it via
     * `click()`. Both announce `zazz:command-select` and close the panel unless
     * the item asks to stay open.
     *
     * @param item - The activated item.
     * @param source - How the commit happened.
     */
    commit(item, source) {
        // The synthetic click() below bubbles back here as a pointer commit:
        // one keystroke must announce once and close once.
        if (this.#clicking)
            return;
        this.#activate(item, source === "keyboard");
    }
    /** True while `#activate` runs an item's native activation via `click()`. */
    #clicking = false;
    /**
     * @description Runs one item's activation: optional synthetic click,
     * `zazz:command-select`, then close (unless `data-command-stay-open`).
     *
     * @param item - The item to activate.
     * @param click - Whether to run the native activation via `click()`.
     * @private
     */
    #activate(item, click) {
        this.dispatchEvent(new CustomEvent("zazz:command-select", {
            bubbles: true,
            detail: { item, value: this.itemValue(item) },
        }));
        if (click) {
            this.#clicking = true;
            try {
                item.click();
            }
            finally {
                this.#clicking = false;
            }
        }
        if (!item.hasAttribute("data-command-stay-open")) {
            this.#closePanel();
        }
    }
    /**
     * @description Opens or closes the panel, branching on its surface.
     * @private
     */
    #togglePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
            else
                panel.showModal();
        }
        else {
            panel.togglePopover();
        }
    }
    /**
     * @description Closes the panel, branching on its surface.
     * @private
     */
    #closePanel() {
        const panel = this.panel;
        if (!panel)
            return;
        if (panel instanceof HTMLDialogElement) {
            if (panel.open)
                panel.close();
        }
        else if (panel.matches(":popover-open")) {
            panel.hidePopover();
        }
    }
}
defineZazzElement("ui-command", UiCommand);
export { UiCommand };

// primitives/command/command-actions.js
"use strict";
/**
 * @fileoverview Example: composing custom command actions for `<ui-command>`.
 * @description This file is a template, not part of the kit runtime: copy it
 * into your project and register your own actions. It is loaded only by the
 * `command-actions` docs example.
 *
 * Simple actions need no script at all: a navigation item is an `<a href>`,
 * and opening a dialog/popover is a `<button command commandfor>` invoker.
 * Reach for a script only when an action runs real logic. Two hooks exist:
 *
 * 1. **Custom invoker commands**: give the item `command="--your-action"`
 *    and `commandfor="<target id>"`, then listen for the `command` event on
 *    the target. The invokers polyfill re-dispatches commands in browsers
 *    with native support, so de-dupe per task (see below).
 * 2. **`zazz:command-select`**: every activation bubbles this CustomEvent
 *    from the `<ui-command>` root (`detail: { item, value }`); use it for
 *    palette-wide concerns like analytics.
 */
/**
 * @description Binds this page's custom `--command` handlers on the command
 * *target* (the `commandfor` element).
 */
function initCommandActions() {
    const themeTarget = document.getElementById("command-actions-target");
    if (!themeTarget)
        return;
    // The invokers polyfill can deliver the same command twice in one task, even
    // with native support.
    let lastEvent = null;
    themeTarget.addEventListener("command", (event) => {
        if (event === lastEvent)
            return;
        lastEvent = event;
        const command = event.command;
        switch (command) {
            case "--theme-toggle": {
                // A theme-dark / theme-light switch pins the scheme; neither follows the system
                const root = document.documentElement;
                const tokens = (root.dataset.ui ?? "").split(/\s+/).filter(Boolean);
                const pinned = tokens.find((t) => t === "theme-dark" || t === "theme-light");
                const dark = pinned
                    ? pinned === "theme-dark"
                    : matchMedia("(prefers-color-scheme: dark)").matches;
                root.dataset.ui = [
                    ...tokens.filter((t) => !t.startsWith("theme-")),
                    dark ? "theme-light" : "theme-dark",
                ].join(" ");
                break;
            }
            case "--copy-link":
                void navigator.clipboard?.writeText(window.location.href);
                break;
        }
    });
}
/**
 * @description Logs every command activation: swap for analytics, recents
 * tracking, or any cross-cutting concern.
 */
function initCommandSelectLogging() {
    document.addEventListener("zazz:command-select", (event) => {
        const detail = event.detail;
        console.info("[command] selected:", detail.value);
    });
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
    const init = () => {
        initCommandActions();
        initCommandSelectLogging();
    };
    if (document.readyState === "loading")
        document.addEventListener("DOMContentLoaded", init);
    else
        init();
}
export { initCommandActions, initCommandSelectLogging };

Behavior

primitives/command/command.js

<ui-command>: a command menu for search and quick actions. Light-DOM custom element on the shared typeahead engine (base/typeahead.ts). The panel (a popover="auto" dropdown or a <dialog data-ui="dialog">) contains the search input; items rank by match score and the best match auto-highlights.

Actions are the platform's own vocabulary: navigation items are real <a href> links, and action items are <button command commandfor> invoker buttons (including custom --commands). Enter activates the highlighted item exactly as a click would; every activation also dispatches a bubbling zazz:command-select CustomEvent (detail: { item, value }) from the root. Without JavaScript the trigger still opens the panel natively and every item still works: only filtering, highlight, and hotkeys are lost.

Attributes:

  • data-command-hotkey (root): global toggle shortcut, e.g. "mod+k".
  • data-command-hotkey (item): global accelerator that activates the item, active while the element is connected (even with the panel closed).
  • data-command-stay-open (item): keep the panel open after activation.
  • data-command-sort="document" (root): opt out of score ranking (default: score).
  • data-command-filter="none" (root): opt out of filtering for a list another source already filtered (a search index); items added later are highlighted and navigable like typed matches.

Parts: command-open (trigger), command-panel, command-header, command-input, command-list, command-group / command-group-label, command-item (data-command-value, data-command-keywords), command-kbd, command-footer, command-empty.

For complex custom actions, see the example command-actions.ts: listen for your --command on its target, or for zazz:command-select on the root.

primitives/command/command-actions.js

Example: composing custom command actions for <ui-command>. This file is a template, not part of the kit runtime: copy it into your project and register your own actions. It is loaded only by the command-actions docs example.

Simple actions need no script at all: a navigation item is an <a href>, and opening a dialog/popover is a <button command commandfor> invoker. Reach for a script only when an action runs real logic. Two hooks exist:

1. Custom invoker commands: give the item command="--your-action" and commandfor="<target id>", then listen for the command event on the target. The invokers polyfill re-dispatches commands in browsers with native support, so de-dupe per task (see below). 2. zazz:command-select: every activation bubbles this CustomEvent from the <ui-command> root (detail: { item, value }); use it for palette-wide concerns like analytics.

Events: zazz:command-select, zazz:dialog-close

Attributes

command attributes
AttributeKindValuesNotes
data-ui="command"Identityfree valueIdentity token in data-ui.
<ui-command>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-command-slotSlotempty footer group group-label header input item list open panelSlot: a part inside the primitive.
data-command-stateStatehighlightedState the kit's script writes; read it in selectors, never author it.
data-command-filterConfigfree valueConfiguration the kit's script reads.
data-command-hotkeyConfigfree valueConfiguration the kit's script reads.
data-command-keywordsConfigfree valueConfiguration the kit's script reads.
data-command-min-lengthConfigfree valueConfiguration the kit's script reads.
data-command-sortConfigfree valueConfiguration the kit's script reads.
data-command-stay-openConfigfree valueConfiguration the kit's script reads.
data-command-valueConfigfree valueConfiguration the kit's script reads.

Requires: popover, button, fields, dialog, menu, select, kbd.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

command hooks
HookDefaultState
--ui-command-footer-border1px solid var(--color-border)—
--ui-command-footer-pcalc(var(--spacing) * 2)—
--ui-command-group-label-paddingcalc(var(--spacing) * 2) calc(var(--spacing) * 2) var(--spacing)—
--ui-command-input-border1px solid var(--color-border)—
--ui-command-input-pcalc(var(--spacing) * 3)—
--ui-command-max-hcalc(var(--spacing) * 96)—
--ui-command-min-wcalc(var(--spacing) * 96)—
--ui-command-option-gapvar(--ui-field-option-gap)—
--ui-command-shadowvar(--ui-menu-shadow)—