Skip to content
Domphy

Command

Build a command palette with three coordinated patches. Apply command() to the outer container — it creates a shared context that carries a live query State. Place a commandSearch() input inside to wire the text field into that query, then add commandItem() entries that hide themselves automatically when their text does not match the current query. Items also check the active query immediately on mount, so items added dynamically after a search is typed are correctly filtered.

commandSearch props

PropTypeDefaultDescription
colorThemeColor"neutral"Base color tone for the search input.
accentColorThemeColor"primary"Accent color used for the focus border.

commandItem props

PropTypeDefaultDescription
colorThemeColor"neutral"Base color tone for the item.
accentColorThemeColor"primary"Accent color used for the focus ring.
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import { merge, type PartialElement, toState } from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";
import { focusRing } from "../utils/focusRing.js";

/**
 * Command-palette container patch. Sets up a vertical flex column and provides a
 * shared `command` context (a query State) consumed by `commandSearch` and
 * `commandItem` descendants to filter the list. Typically applied to a `<div>`.
 *
 * @example { div: [...], $: [command()] }
 */
function command(): PartialElement {
  return {
    // Group (not listbox): search input + option buttons share one container;
    // listbox forbids non-option children (aria-required-children).
    role: "group",
    ariaLabel: "Commands",
    _onSchedule: (_node, element) => {
      merge(element, {
        _context: {
          command: {
            query: toState(""),
          },
        },
      });
    },
    style: {
      display: "flex",
      flexDirection: "column",
      overflow: "hidden",
    },
  };
}

/**
 * Search input for a command palette. Wires the input's value into the parent
 * `command` context's query State so descendant `commandItem`s filter live.
 * Apply to an `<input>` element used inside a `command()`.
 *
 * @hostTag input
 * @param props.color - Base theme color tone. Defaults to "neutral".
 * @param props.accentColor - Accent color used for the focus border. Defaults to "primary".
 * @example { input: "", $: [commandSearch({ accentColor: "primary" })] }
 */
function commandSearch(
  props: { color?: ThemeColor; accentColor?: ThemeColor } = {},
): PartialElement {
  const { color = "neutral", accentColor = "primary" } = props;
  return {
    _onInsert: (node) => {
      if (node.tagName !== "input") {
        console.warn(`"commandSearch" patch must use input tag`);
      }
    },
    _onMount: (node) => {
      const ctx = node.getContext("command");
      if (!ctx) {
        console.warn(`"commandSearch" patch must be used inside a "command"`);
        return;
      }
      const input = node.domElement as HTMLInputElement;
      const onInput = () => ctx.query.set(input.value);
      input.addEventListener("input", onInput);
      node.addHook("Remove", () => input.removeEventListener("input", onInput));
    },
    style: {
      fontFamily: "inherit",
      fontSize: (listener) => themeSize(listener, "inherit"),
      paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3),
      paddingBlock: (listener) => themeSpacing(themeDensity(listener) * 2),
      border: "none",
      borderBottom: (listener) =>
        `1px solid ${themeColor(listener, "border", color)}`,
      outline: "none",
      color: (listener) => themeColor(listener, "shift-10", color),
      backgroundColor: (listener) => themeColor(listener, "inherit", color),
      "&::placeholder": {
        color: (listener) => themeColor(listener, "shift-7"),
      },
      transition: "border-bottom-color 140ms ease, box-shadow 140ms ease",
      "&:focus-visible": {
        borderBottomColor: (listener) =>
          themeColor(listener, "shift-6", accentColor),
        boxShadow: (listener) => focusRing(listener, accentColor),
      },
    },
  };
}

/**
 * Selectable item in a command palette. On mount, immediately hides itself if
 * the current query doesn't match its text content, and subscribes to future
 * query changes — so items added dynamically after a search is typed are
 * correctly filtered. Typically applied to a `<button>` (or any clickable
 * element) used inside a `command()`. Uses native button semantics (not
 * `role=option`) so the search input may sit as a sibling without a listbox
 * parent requirement.
 *
 * @param props.color - Base theme color tone. Defaults to "neutral".
 * @param props.accentColor - Accent color used for the focus ring. Defaults to "primary".
 * @example { button: "Open file", $: [commandItem({ color: "neutral" })] }
 */
function commandItem(
  props: { color?: ThemeColor; accentColor?: ThemeColor } = {},
): PartialElement {
  const { color = "neutral", accentColor = "primary" } = props;
  return {
    _onMount: (node) => {
      const ctx = node.getContext("command");
      if (!ctx) {
        console.warn(`"commandItem" patch must be used inside a "command"`);
        return;
      }
      const el = node.domElement as HTMLElement;
      const text = el.textContent?.toLowerCase() ?? "";
      const applyFilter = (q: string) => {
        el.hidden = q.length > 0 && !text.includes(q.toLowerCase());
      };
      applyFilter(ctx.query.get());
      const release = ctx.query.addListener(applyFilter);
      node.addHook("Remove", release);
    },
    style: {
      cursor: "pointer",
      display: "flex",
      alignItems: "center",
      width: "100%",
      fontSize: (listener) => themeSize(listener, "inherit"),
      height: (listener) => themeSpacing(6 + themeDensity(listener) * 2),
      paddingInline: (listener) => themeSpacing(themeDensity(listener) * 3),
      border: "none",
      outline: "none",
      // shift-13: menu-row labels need ≥4.5:1 on light surface (visual catalog).
      color: (listener) => themeColor(listener, "shift-13", color),
      backgroundColor: (listener) => themeColor(listener, "inherit", color),
      transition: "background-color 140ms ease, box-shadow 140ms ease",
      "&:hover:not([disabled])": {
        color: (listener) => themeColor(listener, "shift-13", color),
        backgroundColor: (listener) => themeColor(listener, "hover", color),
      },
      "&:focus-visible": {
        boxShadow: (listener) => focusRing(listener, accentColor),
      },
    },
  };
}

export { command, commandSearch, commandItem };