{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "command",
  "title": "Command",
  "type": "registry:ui",
  "description": "Searchable command palette à la Cmd-K — keyboard-driven menu with grouped items, icons, shortcuts, and fuzzy filtering. Use as a global launcher (mounted in a Dialog) or inline as a typeahead select.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/command/command.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { Search } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\ninterface Item {\n  el: Element\n  value: string\n  keywords: string[]\n  disabled: boolean\n  nodes: Node[]\n}\ntype Entry = { kind: 'group'; heading?: string; value: string; items: Item[] } | { kind: 'separator' }\n\nlet uid = 0\n\nconst rootClasses = 'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md'\nconst dialogRootClasses =\n  '[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-1 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5'\n\n/** cmdk-style fuzzy match: every search character appears in order. */\nfunction matches(haystack: string, search: string) {\n  const h = haystack.toLowerCase()\n  let i = 0\n  for (const ch of search.toLowerCase().replace(/\\s+/g, '')) {\n    i = h.indexOf(ch, i)\n    if (i < 0) return false\n    i++\n  }\n  return true\n}\n\n/** Clone an item's light-DOM children for rendering; <uip-command-shortcut> becomes CommandShortcut's span. */\nfunction cloneContent(el: Element): Node[] {\n  return [...el.childNodes].map((n) => {\n    if (n instanceof Element && n.localName === 'uip-command-shortcut') {\n      const span = document.createElement('span')\n      span.setAttribute('data-uipkge', '')\n      span.setAttribute('data-slot', 'command-shortcut')\n      span.className = 'text-muted-foreground ml-auto text-xs tracking-widest'\n      span.textContent = n.textContent\n      return span\n    }\n    return n.cloneNode(true)\n  })\n}\n\n/**\n * <uip-command> — the registry Command (cmdk: Command + CommandInput +\n * CommandList + CommandEmpty + CommandGroup + CommandItem + CommandSeparator +\n * CommandShortcut) as ONE web component. <uip-command-dialog> is\n * CommandDialog: the same element inside a native modal <dialog>.\n *\n *   <uip-command placeholder=\"Type a command or search…\">\n *     <uip-command-empty>No results found.</uip-command-empty>\n *     <uip-command-group heading=\"Suggestions\">\n *       <uip-command-item value=\"calendar\"><svg …></svg>Calendar</uip-command-item>\n *       <uip-command-item value=\"profile\">…Profile<uip-command-shortcut>⌘P</uip-command-shortcut></uip-command-item>\n *     </uip-command-group>\n *     <uip-command-separator></uip-command-separator>\n *   </uip-command>\n *\n * Children are data, like <uip-select>'s <option>s: the element reads them and\n * renders the input (role=combobox) and the listbox in ONE shadow root, so\n * aria-controls / aria-activedescendant resolve. Focus stays in the input.\n * Filtering is cmdk's: fuzzy match on `value` + `keywords` (value defaults to\n * the item text); groups with no match and separators hide while searching.\n * Arrow keys / Home / End / Ctrl+N/P/J/K move, Enter selects. Anything with\n * `slot=\"list\"` renders at the end of the list (loading states).\n *\n * The host is Command's root box, so React's `className` goes on the host\n * (`class=\"max-w-md rounded-lg border shadow-sm\"`).\n *\n * Properties: `placeholder`, `label` (sr-only input label), `loop`, `search`\n * (input text), `value` (highlighted item), `filter` (cmdk's custom filter\n * fn — `(value, search, keywords) => number | boolean`, set via property;\n * numbers > 0 show, 0 hides; order is preserved, no re-sorting),\n * `should-filter` (default true; `=\"false\"` disables built-in filtering and\n * shows all items — cmdk's `shouldFilter`).\n * Events: `select` on the chosen <uip-command-item> (bubbles, composed;\n * detail: { value }) — CommandItem's onSelect; `value-change` (detail:\n * { value }) — Command's onValueChange; the inner input's `input` event.\n */\nexport type CommandFilter = (value: string, search: string, keywords?: string[]) => number | boolean\n\nexport class UipCommand extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    placeholder: {},\n    label: {},\n    loop: { type: Boolean },\n    search: {},\n    value: {},\n    filter: { attribute: false },\n    shouldFilter: {\n      type: Boolean,\n      attribute: 'should-filter',\n      converter: { fromAttribute: (v: string | null) => v !== null && v !== 'false' },\n    },\n    entries: { state: true },\n    emptyNodes: { state: true },\n  }\n\n  placeholder?: string\n  label?: string\n  loop = false\n  search = ''\n  value = ''\n  filter?: CommandFilter\n  shouldFilter = true\n  protected entries: Entry[] = []\n  protected emptyNodes: Node[] | null = null\n  protected readonly uidBase = `uip-command-${++uid}`\n  protected dialogMode = false\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', this.dialogMode ? 'command-dialog' : 'command')\n    this.readItems()\n    this.childObserver = new MutationObserver(() => this.readItems())\n    this.childObserver.observe(this, { childList: true, subtree: true, characterData: true, attributes: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n  }\n\n  private readItems() {\n    const item = (el: Element): Item => ({\n      el,\n      value: (el.getAttribute('value') ?? el.textContent ?? '').trim(),\n      keywords: (el.getAttribute('keywords') ?? '').split(',').map((k) => k.trim()).filter(Boolean),\n      disabled: el.hasAttribute('disabled'),\n      nodes: cloneContent(el),\n    })\n    const entries: Entry[] = []\n    let loose: Entry | undefined\n    for (const child of this.children) {\n      if (child.localName === 'uip-command-group') {\n        loose = undefined\n        entries.push({\n          kind: 'group',\n          heading: child.getAttribute('heading') ?? undefined,\n          value: child.getAttribute('value') ?? child.getAttribute('heading') ?? '',\n          items: [...child.children].filter((c) => c.localName === 'uip-command-item').map(item),\n        })\n      } else if (child.localName === 'uip-command-item') {\n        // Items outside a group render in an unlabelled group, like cmdk.\n        if (!loose) entries.push((loose = { kind: 'group', value: '', items: [] }))\n        if (loose.kind === 'group') loose.items.push(item(child))\n      } else if (child.localName === 'uip-command-separator') {\n        loose = undefined\n        entries.push({ kind: 'separator' })\n      }\n    }\n    this.entries = entries\n    const empty = this.querySelector(':scope > uip-command-empty')\n    this.emptyNodes = empty ? cloneContent(empty) : null\n  }\n\n  // --- filtering / selection -------------------------------------------------\n  private isVisible(i: Item) {\n    if (!this.shouldFilter || !this.search) return true\n    if (this.filter) {\n      const score = this.filter(i.value, this.search, i.keywords)\n      return typeof score === 'number' ? score > 0 : Boolean(score)\n    }\n    return matches([i.value, ...i.keywords].join(' '), this.search)\n  }\n\n  /** Visible items in render order. */\n  private get visible() {\n    return this.entries.flatMap((e) => (e.kind === 'group' ? e.items.filter((i) => this.isVisible(i)) : []))\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    // cmdk selects the first item whenever the search or the items change and\n    // the current one is gone (a new filter / shouldFilter re-evaluates too).\n    if (changed.has('search') || changed.has('entries') || changed.has('filter') || changed.has('shouldFilter')) {\n      const vis = this.visible.filter((i) => !i.disabled)\n      if (changed.has('search') || changed.has('filter') || changed.has('shouldFilter') || !vis.some((i) => i.value === this.value))\n        this.setValue(vis[0]?.value ?? '')\n    }\n  }\n\n  private setValue(value: string) {\n    if (value === this.value) return\n    this.value = value\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value }, bubbles: true, composed: true }))\n  }\n\n  private move(dir: 1 | -1 | 'first' | 'last') {\n    const vis = this.visible.filter((i) => !i.disabled)\n    if (!vis.length) return\n    const cur = vis.findIndex((i) => i.value === this.value)\n    let next: number\n    if (dir === 'first') next = 0\n    else if (dir === 'last') next = vis.length - 1\n    else {\n      next = cur + dir\n      if (next < 0) next = this.loop ? vis.length - 1 : 0\n      if (next >= vis.length) next = this.loop ? 0 : vis.length - 1\n    }\n    this.setValue(vis[next].value)\n    this.updateComplete.then(() =>\n      this.renderRoot.querySelector('[cmdk-item][data-selected=\"true\"]')?.scrollIntoView({ block: 'nearest' }),\n    )\n  }\n\n  protected selectItem(item: Item) {\n    if (item.disabled) return\n    this.setValue(item.value)\n    item.el.dispatchEvent(new CustomEvent('select', { detail: { value: item.value }, bubbles: true, composed: true }))\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    const k = e.key\n    const vim = e.ctrlKey && !e.metaKey && !e.altKey\n    if (k === 'ArrowDown' || (vim && (k === 'n' || k === 'j'))) {\n      e.preventDefault()\n      this.move(1)\n    } else if (k === 'ArrowUp' || (vim && (k === 'p' || k === 'k'))) {\n      e.preventDefault()\n      this.move(-1)\n    } else if (k === 'Home') {\n      e.preventDefault()\n      this.move('first')\n    } else if (k === 'End') {\n      e.preventDefault()\n      this.move('last')\n    } else if (k === 'Enter' && !e.isComposing) {\n      const item = this.visible.find((i) => i.value === this.value)\n      if (item) {\n        e.preventDefault()\n        this.selectItem(item)\n      }\n    }\n  }\n\n  private onInput(e: Event) {\n    this.search = (e.target as HTMLInputElement).value\n  }\n\n  /** Focus the search input. */\n  focus(options?: FocusOptions) {\n    this.renderRoot?.querySelector<HTMLInputElement>('[cmdk-input]')?.focus(options)\n  }\n\n  // --- render ----------------------------------------------------------------\n  protected renderCommand(extraClass?: string) {\n    const listId = `${this.uidBase}-list`\n    const inputId = `${this.uidBase}-input`\n    const labelId = `${this.uidBase}-label`\n    const itemId = (i: Item) => `${this.uidBase}-item-${this.itemIndex(i)}`\n    const selected = this.visible.find((i) => i.value === this.value)\n    const nVisible = this.visible.length\n    return html`<div\n      part=\"base\"\n      cmdk-root=\"\"\n      data-uipkge=\"\"\n      data-slot=\"command\"\n      class=${cn(rootClasses, extraClass)}\n      @keydown=${this.onKeyDown}\n    >\n      <label cmdk-label=\"\" for=${inputId} id=${labelId} class=\"sr-only\">${this.label ?? ''}</label>\n      <div data-uipkge=\"\" data-slot=\"command-input-wrapper\" cmdk-input-wrapper=\"\" class=\"flex h-9 items-center gap-2 border-b px-3\">\n        ${icon(Search, 'search', 'text-muted-foreground size-4 shrink-0')}\n        <input\n          id=${inputId}\n          cmdk-input=\"\"\n          data-uipkge=\"\"\n          data-slot=\"command-input\"\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded=\"true\"\n          aria-controls=${listId}\n          aria-labelledby=${labelId}\n          aria-activedescendant=${selected ? itemId(selected) : nothing}\n          autocomplete=\"off\"\n          autocorrect=\"off\"\n          spellcheck=\"false\"\n          type=\"text\"\n          placeholder=${this.placeholder ?? nothing}\n          .value=${this.search}\n          class=\"placeholder:text-muted-foreground focus-visible:ring-ring/40 flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n          @input=${this.onInput}\n        />\n      </div>\n      <div\n        id=${listId}\n        cmdk-list=\"\"\n        role=\"listbox\"\n        tabindex=\"-1\"\n        aria-label=\"Suggestions\"\n        data-uipkge=\"\"\n        data-slot=\"command-list\"\n        class=\"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\"\n      >\n        <div cmdk-list-sizer=\"\">\n          ${nVisible === 0 && this.emptyNodes\n            ? html`<div cmdk-empty=\"\" role=\"presentation\" data-uipkge=\"\" data-slot=\"command-empty\" class=\"py-6 text-center text-sm\">\n                ${this.emptyNodes}\n              </div>`\n            : nothing}\n          ${this.entries.map((entry, gi) => {\n            if (entry.kind === 'separator') {\n              return this.search && this.shouldFilter\n                ? nothing\n                : html`<div cmdk-separator=\"\" role=\"separator\" data-uipkge=\"\" data-slot=\"command-separator\" class=\"bg-border -mx-1 h-px\"></div>`\n            }\n            const items = entry.items.filter((i) => this.isVisible(i))\n            if (!items.length) return nothing\n            const headingId = `${this.uidBase}-group-${gi}`\n            return html`<div\n              cmdk-group=\"\"\n              role=\"presentation\"\n              data-value=${entry.value}\n              data-uipkge=\"\"\n              data-slot=\"command-group\"\n              class=\"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\"\n            >\n              ${entry.heading\n                ? html`<div cmdk-group-heading=\"\" aria-hidden=\"true\" id=${headingId}>${entry.heading}</div>`\n                : nothing}\n              <div cmdk-group-items=\"\" role=\"group\" aria-labelledby=${entry.heading ? headingId : nothing}>\n                ${items.map((item) => {\n                  const isSel = item.value === this.value\n                  return html`<div\n                    id=${itemId(item)}\n                    cmdk-item=\"\"\n                    role=\"option\"\n                    aria-disabled=${item.disabled ? 'true' : 'false'}\n                    aria-selected=${isSel ? 'true' : 'false'}\n                    data-disabled=${item.disabled ? 'true' : 'false'}\n                    data-selected=${isSel ? 'true' : 'false'}\n                    data-value=${item.value}\n                    data-uipkge=\"\"\n                    data-slot=\"command-item\"\n                    class=\"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n                    @pointermove=${() => !item.disabled && this.setValue(item.value)}\n                    @click=${() => this.selectItem(item)}\n                  >\n                    ${item.nodes}\n                  </div>`\n                })}\n              </div>\n            </div>`\n          })}\n          <slot name=\"list\"></slot>\n        </div>\n      </div>\n    </div>`\n  }\n\n  private itemIndex(item: Item) {\n    let n = 0\n    for (const e of this.entries) {\n      if (e.kind !== 'group') continue\n      const i = e.items.indexOf(item)\n      if (i >= 0) return n + i\n      n += e.items.length\n    }\n    return -1\n  }\n\n  render() {\n    return this.renderCommand()\n  }\n}\n\n/**\n * <uip-command-dialog> — CommandDialog: <uip-command> inside a native modal\n * <dialog> (top layer, focus trap, Escape, focus returned to the opener). The\n * overlay is the dialog's ::backdrop. `heading` / `description` are the\n * sr-only DialogTitle / DialogDescription (React's `title` / `description`;\n * `title` is an HTMLElement attribute, so it's `heading` here).\n *\n * Properties: `open`, `heading`, `description` + everything <uip-command> has.\n * Events: `open-change` (detail: { open }) + <uip-command>'s events.\n */\nexport class UipCommandDialog extends UipCommand {\n  // Only the <dialog> is visible; the host takes no layout space.\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    ...UipCommand.properties,\n    open: { type: Boolean, reflect: true },\n    heading: {},\n    description: {},\n    state: { state: true },\n  }\n\n  open = false\n  heading = 'Command Palette'\n  description = 'Search for a command to run...'\n  private state: 'open' | 'closed' = 'closed'\n\n  constructor() {\n    super()\n    this.dialogMode = true\n  }\n\n  private get dialog() {\n    return this.renderRoot.querySelector('dialog')\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  close() {\n    this.setOpen(false)\n  }\n\n  private setOpen(open: boolean) {\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('open')) {\n      this.state = this.open ? 'open' : 'closed'\n      // Each opening starts with an empty search (the palette is re-mounted in React).\n      if (this.open && this.search) {\n        changed.set('search', this.search)\n        this.search = ''\n      }\n    }\n    super.willUpdate(changed)\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (!changed.has('open')) return\n    const dialog = this.dialog\n    if (!dialog) return\n    if (this.open && !dialog.open) {\n      dialog.showModal()\n    } else if (!this.open && dialog.open) {\n      let closed = false\n      const done = () => {\n        if (closed || this.open) return\n        closed = true\n        dialog.close()\n      }\n      setTimeout(done, 400)\n      const anims = dialog.getAnimations()\n      if (anims.length) Promise.all(anims.map((a) => a.finished)).then(done, done)\n      else done()\n    }\n  }\n\n  private onCancel(e: Event) {\n    e.preventDefault()\n    this.close()\n  }\n\n  private onDialogClick(e: MouseEvent) {\n    if (e.target === this.dialog) this.close()\n  }\n\n  render() {\n    return html`<dialog\n      part=\"content\"\n      data-uipkge=\"\"\n      data-slot=\"command-dialog\"\n      data-state=${this.state}\n      aria-labelledby=${`${this.uidBase}-title`}\n      aria-describedby=${`${this.uidBase}-description`}\n      class=\"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 open:grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-hidden rounded-lg border p-0 shadow-lg duration-200 m-0 backdrop:bg-black/50\"\n      @cancel=${this.onCancel}\n      @close=${() => this.close()}\n      @click=${this.onDialogClick}\n    >\n      <h2 id=${`${this.uidBase}-title`} class=\"sr-only\">${this.heading}</h2>\n      <p id=${`${this.uidBase}-description`} class=\"sr-only\">${this.description}</p>\n      ${this.renderCommand(dialogRootClasses)}\n    </dialog>`\n  }\n}\n\ncustomElements.get('uip-command') || customElements.define('uip-command', UipCommand)\ncustomElements.get('uip-command-dialog') || customElements.define('uip-command-dialog', UipCommandDialog)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-command': UipCommand\n    'uip-command-dialog': UipCommandDialog\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/command/command.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
