API
Command
A command palette: search and quick actions behind a shortcut.
Installation
pnpm dlx zazz-ui add commandnpx zazz-ui add commandyarn dlx zazz-ui add commandbunx zazz-ui add commandAlso 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
<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
<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.
<!-- 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
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="command" | Identity | free value | Identity token in data-ui. |
<ui-command> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-command-slot | Slot | empty footer group group-label header input item list open panel | Slot: a part inside the primitive. |
data-command-state | State | highlighted | State the kit's script writes; read it in selectors, never author it. |
data-command-filter | Config | free value | Configuration the kit's script reads. |
data-command-hotkey | Config | free value | Configuration the kit's script reads. |
data-command-keywords | Config | free value | Configuration the kit's script reads. |
data-command-min-length | Config | free value | Configuration the kit's script reads. |
data-command-sort | Config | free value | Configuration the kit's script reads. |
data-command-stay-open | Config | free value | Configuration the kit's script reads. |
data-command-value | Config | free value | Configuration 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.
| Hook | Default | State |
|---|---|---|
--ui-command-footer-border | 1px solid var(--color-border) | — |
--ui-command-footer-p | calc(var(--spacing) * 2) | — |
--ui-command-group-label-padding | calc(var(--spacing) * 2) calc(var(--spacing) * 2) var(--spacing) | — |
--ui-command-input-border | 1px solid var(--color-border) | — |
--ui-command-input-p | calc(var(--spacing) * 3) | — |
--ui-command-max-h | calc(var(--spacing) * 96) | — |
--ui-command-min-w | calc(var(--spacing) * 96) | — |
--ui-command-option-gap | var(--ui-field-option-gap) | — |
--ui-command-shadow | var(--ui-menu-shadow) | — |