{"name":"command","type":"registry:component","title":"Command","description":"Fast, composable, unstyled command menu component. Features search/filtering, keyboard navigation, item grouping, and accessibility support. Based on the cmdk library pattern.","categories":["ui","command","search","menu","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":["lucide-static"],"files":[{"path":"registry/ui/command/command.ts","type":"registry:ui","content":"import { css, html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport { customElement, property, query, state } from \"lit/decorators.js\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { Search } from \"lucide-static\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\nimport { cn } from \"@/registry/lib/utils\";\n\n// Type definitions\nexport type FilterFunction = (\n  value: string,\n  search: string,\n  keywords?: string[],\n) => number;\n\n// Default filter function (similar to cmdk)\nconst defaultFilter: FilterFunction = (value, search, keywords = []) => {\n  const searchLower = search.toLowerCase().trim();\n  if (!searchLower) return 1;\n\n  const valueLower = value.toLowerCase();\n  const keywordsLower = keywords.map((k) => k.toLowerCase());\n\n  // Exact match = highest score\n  if (valueLower === searchLower) return 2;\n\n  // Starts with = high score\n  if (valueLower.startsWith(searchLower)) return 1.5;\n\n  // Contains = medium score\n  if (valueLower.includes(searchLower)) return 1;\n\n  // Check keywords\n  for (const keyword of keywordsLower) {\n    if (keyword.includes(searchLower)) return 0.8;\n  }\n\n  return 0; // No match\n};\n\n// Component properties interfaces\nexport interface CommandProperties {\n  value?: string;\n  filter?: FilterFunction;\n  shouldFilter?: boolean;\n  loop?: boolean;\n  label?: string;\n}\n\nexport interface CommandInputProperties {\n  value?: string;\n  placeholder?: string;\n}\n\nexport interface CommandItemProperties {\n  value?: string;\n  keywords?: string[];\n  disabled?: boolean;\n  forceMount?: boolean;\n}\n\nexport interface CommandGroupProperties {\n  heading?: string;\n  forceMount?: boolean;\n}\n\n// Helper type for items with internal state\nexport type CommandItemWithState = CommandItem & {\n  _score: number;\n  _highlighted: boolean;\n};\n\n/**\n * Main Command component - root container\n */\n@customElement(\"ui-command\")\nexport class Command extends TW(LitElement) implements CommandProperties {\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  @property({ type: String, reflect: true }) value = \"\";\n  @property({ type: Boolean, attribute: \"should-filter\" }) shouldFilter = true;\n  @property({ type: Boolean }) loop = false;\n  @property({ type: String }) label = \"Command Menu\";\n  @property({ attribute: false }) filter?: FilterFunction;\n\n  @state() private _search = \"\";\n  @state() private _filteredCount = 0;\n  @state() private _statusMessage = \"\";\n\n  // Store references to all items and groups for filtering\n  private _items = new Set<CommandItem>();\n  private _groups = new Set<CommandGroup>();\n  private _filterDebounceId?: number;\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.addEventListener(\"command-input-change\", this._handleInputChange);\n    this.addEventListener(\"command-item-select\", this._handleItemSelect);\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\"command-input-change\", this._handleInputChange);\n    this.removeEventListener(\"command-item-select\", this._handleItemSelect);\n    if (this._filterDebounceId) {\n      cancelAnimationFrame(this._filterDebounceId);\n    }\n  }\n\n  override willUpdate(changedProperties: PropertyValues) {\n    super.willUpdate(changedProperties);\n    if (\n      changedProperties.has(\"_search\") ||\n      changedProperties.has(\"shouldFilter\")\n    ) {\n      this._scheduleFilterUpdate();\n    }\n  }\n\n  registerItem(item: CommandItem) {\n    this._items.add(item);\n    this._scheduleFilterUpdate();\n  }\n\n  unregisterItem(item: CommandItem) {\n    this._items.delete(item);\n    this._scheduleFilterUpdate();\n  }\n\n  registerGroup(group: CommandGroup) {\n    this._groups.add(group);\n  }\n\n  unregisterGroup(group: CommandGroup) {\n    this._groups.delete(group);\n  }\n\n  private _scheduleFilterUpdate() {\n    if (this._filterDebounceId) {\n      cancelAnimationFrame(this._filterDebounceId);\n    }\n    this._filterDebounceId = requestAnimationFrame(() => {\n      this._updateFilter();\n    });\n  }\n\n  private _updateFilter() {\n    if (!this.shouldFilter) return;\n\n    const filterFn = this.filter || defaultFilter;\n    let visibleCount = 0;\n\n    // Filter items using public methods\n    for (const item of this._items) {\n      const score = filterFn(item.value, this._search, item.keywords);\n      item.updateScore(score);\n      if (score > 0) visibleCount++;\n    }\n\n    // Update groups visibility using public methods\n    for (const group of this._groups) {\n      const hasVisibleItems = group.hasVisibleItems();\n      group.setHidden(!hasVisibleItems && !group.forceMount);\n    }\n\n    this._filteredCount = visibleCount;\n\n    // Update status message for screen readers\n    if (this._search) {\n      if (visibleCount === 0) {\n        this._statusMessage = \"No results found\";\n      } else if (visibleCount === 1) {\n        this._statusMessage = \"1 result available\";\n      } else {\n        this._statusMessage = `${visibleCount} results available`;\n      }\n    } else {\n      this._statusMessage = \"\";\n    }\n\n    // Reset highlighted index in list if needed\n    const list = this.querySelector(\"ui-command-list\") as CommandList;\n    if (list) {\n      list.resetHighlightAfterFilter();\n    }\n\n    // Dispatch event for CommandEmpty and other listeners\n    this.dispatchEvent(\n      new CustomEvent(\"filter-update\", {\n        detail: { filteredCount: visibleCount, search: this._search },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  }\n\n  private _handleInputChange = (e: Event) => {\n    const event = e as CustomEvent;\n    this._search = event.detail.search;\n\n    this.dispatchEvent(\n      new CustomEvent(\"search-change\", {\n        detail: { search: this._search },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  private _handleItemSelect = (e: Event) => {\n    const event = e as CustomEvent;\n    this.value = event.detail.value;\n\n    this.dispatchEvent(\n      new CustomEvent(\"select\", {\n        detail: { value: event.detail.value, item: event.detail.item },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n\n    this.dispatchEvent(\n      new CustomEvent(\"value-change\", {\n        detail: { value: this.value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  getSearchValue() {\n    return this._search;\n  }\n\n  getFilteredCount() {\n    return this._filteredCount;\n  }\n\n  override render() {\n    return html`\n      <div\n        class=${cn(\n          \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n        <!-- Live region for screen reader announcements -->\n        <div\n          role=\"status\"\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n          class=\"sr-only absolute\"\n          style=\"position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;\"\n        >\n          ${this._statusMessage}\n        </div>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Input component\n */\n@customElement(\"ui-command-input\")\nexport class CommandInput\n  extends TW(LitElement)\n  implements CommandInputProperties\n{\n  @property({ type: String }) value = \"\";\n  @property({ type: String }) placeholder = \"Type a command or search...\";\n\n  @query(\"input\") private _inputElement!: HTMLInputElement;\n  @state() private _activeDescendant = \"\";\n  @state() private _isExpanded = false;\n  @state() private _listboxId = \"\";\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Setup listbox reference with unique ID\n    this._setupListboxReference();\n\n    // Listen to events on the parent command element (events bubble from list)\n    const command = this.closest(\"ui-command\");\n    if (command) {\n      command.addEventListener(\n        \"highlight-change\",\n        this._handleHighlightChange as EventListener,\n      );\n      command.addEventListener(\n        \"filter-update\",\n        this._handleFilterUpdate as EventListener,\n      );\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n\n    // Remove listeners from parent command element\n    const command = this.closest(\"ui-command\");\n    if (command) {\n      command.removeEventListener(\n        \"highlight-change\",\n        this._handleHighlightChange as EventListener,\n      );\n      command.removeEventListener(\n        \"filter-update\",\n        this._handleFilterUpdate as EventListener,\n      );\n    }\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n    if (changedProperties.has(\"value\") && this._inputElement) {\n      this._inputElement.value = this.value;\n    }\n  }\n\n  private _setupListboxReference() {\n    const list = this.closest(\"ui-command\")?.querySelector(\"ui-command-list\");\n    if (list) {\n      this._listboxId =\n        list.id ||\n        `command-list-${Math.random().toString(36).substring(2, 11)}`;\n      if (!list.id) {\n        list.id = this._listboxId;\n      }\n    }\n  }\n\n  private _handleHighlightChange = (e: CustomEvent) => {\n    // Only set if we have a valid non-empty ID\n    const itemId = e.detail.itemId;\n    this._activeDescendant = itemId && typeof itemId === \"string\" ? itemId : \"\";\n  };\n\n  private _handleFilterUpdate = (e: CustomEvent) => {\n    // Update expanded state based on filtered results\n    this._isExpanded = e.detail.filteredCount > 0;\n  };\n\n  private _handleInput = (e: Event) => {\n    const value = (e.target as HTMLInputElement).value;\n    this.value = value;\n\n    this.dispatchEvent(\n      new CustomEvent(\"command-input-change\", {\n        detail: { search: value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  private _handleKeyDown = (e: KeyboardEvent) => {\n    const list = this.closest(\"ui-command\")?.querySelector(\n      \"ui-command-list\",\n    ) as CommandList;\n    if (!list) return;\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        if (!this._isExpanded && this.value) {\n          this._isExpanded = true;\n        }\n        list._navigateNext();\n        break;\n\n      case \"ArrowUp\":\n        e.preventDefault();\n        list._navigatePrevious();\n        break;\n\n      case \"Enter\":\n        e.preventDefault();\n        list._selectHighlighted();\n        break;\n\n      case \"Escape\":\n        e.preventDefault();\n        if (this.value) {\n          this.value = \"\";\n          this._handleInput(e);\n        } else {\n          this._isExpanded = false;\n        }\n        break;\n\n      case \"Home\":\n        if (e.ctrlKey) {\n          e.preventDefault();\n          list._navigateFirst();\n        }\n        break;\n\n      case \"End\":\n        if (e.ctrlKey) {\n          e.preventDefault();\n          list._navigateLast();\n        }\n        break;\n    }\n  };\n\n  override render() {\n    return html`\n      <div\n        class=\"flex h-9 items-center gap-2 border-b px-3\"\n        cmdk-input-wrapper=\"\"\n      >\n        <span\n          class=\"mr-2 size-4 shrink-0 opacity-50\"\n          aria-hidden=\"true\"\n          role=\"presentation\"\n        >\n          ${unsafeSVG(Search)}\n        </span>\n        <input\n          type=\"text\"\n          role=\"combobox\"\n          aria-expanded=${this._isExpanded ? \"true\" : \"false\"}\n          aria-controls=${this._listboxId || \"command-list\"}\n          aria-autocomplete=\"list\"\n          aria-label=\"Search commands\"\n          aria-activedescendant=${this._activeDescendant}\n          autocomplete=\"off\"\n          autocorrect=\"off\"\n          spellcheck=\"false\"\n          class=${cn(\n            \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none\",\n            \"placeholder:text-muted-foreground\",\n            \"disabled:cursor-not-allowed disabled:opacity-50\",\n          )}\n          placeholder=${this.placeholder}\n          .value=${this.value}\n          @input=${this._handleInput}\n          @keydown=${this._handleKeyDown}\n        />\n      </div>\n    `;\n  }\n}\n\n/**\n * Command List component - handles keyboard navigation\n */\n@customElement(\"ui-command-list\")\nexport class CommandList extends TW(LitElement) {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  @state() private _highlightedIndex = -1;\n\n  // Public methods for external control\n  public resetHighlightAfterFilter(): void {\n    this._resetHighlightAfterFilter();\n  }\n\n  public setHighlightedIndex(index: number): void {\n    const items = this._getNavigableItems();\n    this._highlightedIndex = Math.max(0, Math.min(index, items.length - 1));\n    this._updateHighlighted(items);\n  }\n\n  public getHighlightedIndex(): number {\n    return this._highlightedIndex;\n  }\n\n  public syncHighlightToItem(item: CommandItem): void {\n    const items = this._getNavigableItems();\n    const index = items.indexOf(item as CommandItemWithState);\n    if (index >= 0) {\n      this._highlightedIndex = index;\n      this._updateHighlighted(items);\n    }\n  }\n\n  // Public navigation methods for keyboard control\n  public _navigateNext(): void {\n    const items = this._getNavigableItems();\n    if (items.length === 0) return;\n\n    // Clamp index to valid range before calculation\n    this._highlightedIndex = Math.max(\n      -1,\n      Math.min(this._highlightedIndex, items.length - 1),\n    );\n\n    this._highlightedIndex = this._loop\n      ? (this._highlightedIndex + 1) % items.length\n      : Math.min(this._highlightedIndex + 1, items.length - 1);\n    this._updateHighlighted(items);\n  }\n\n  public _navigatePrevious(): void {\n    const items = this._getNavigableItems();\n    if (items.length === 0) return;\n\n    // Clamp index to valid range before calculation\n    this._highlightedIndex = Math.max(\n      -1,\n      Math.min(this._highlightedIndex, items.length - 1),\n    );\n\n    this._highlightedIndex = this._loop\n      ? (this._highlightedIndex - 1 + items.length) % items.length\n      : Math.max(this._highlightedIndex - 1, 0);\n    this._updateHighlighted(items);\n  }\n\n  public _navigateFirst(): void {\n    const items = this._getNavigableItems();\n    if (items.length === 0) return;\n\n    this._highlightedIndex = 0;\n    this._updateHighlighted(items);\n  }\n\n  public _navigateLast(): void {\n    const items = this._getNavigableItems();\n    if (items.length === 0) return;\n\n    this._highlightedIndex = items.length - 1;\n    this._updateHighlighted(items);\n  }\n\n  public _selectHighlighted(): void {\n    const items = this._getNavigableItems();\n    if (this._highlightedIndex >= 0 && items[this._highlightedIndex]) {\n      items[this._highlightedIndex].click();\n    }\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n    // Get loop setting from parent command\n    const command = this.closest(\"ui-command\") as Command;\n    this._loop = command?.loop || false;\n  }\n\n  private _loop = false;\n\n  private _getNavigableItems(): CommandItemWithState[] {\n    // Query all items within the list (they're nested in groups)\n    const allItems = Array.from(\n      this.querySelectorAll(\"ui-command-item\"),\n    ) as CommandItem[];\n\n    return allItems.filter((item): item is CommandItemWithState => {\n      if (!(item instanceof CommandItem)) return false;\n      return !item.disabled && item.getScore() > 0;\n    });\n  }\n\n  private _updateHighlighted(items: CommandItemWithState[]) {\n    let currentItemId = \"\";\n    items.forEach((item, index) => {\n      const isHighlighted = index === this._highlightedIndex;\n      item.setHighlighted(isHighlighted);\n      if (isHighlighted) {\n        item.scrollIntoView({ block: \"nearest\" });\n        // Ensure item has valid ID before using it\n        const itemElement = item as CommandItem;\n        if (!itemElement.id) {\n          itemElement.id = `command-item-${Math.random().toString(36).substring(2, 11)}`;\n        }\n        currentItemId = itemElement.id;\n      }\n    });\n\n    // Only dispatch event if we have a valid ID\n    if (currentItemId) {\n      this.dispatchEvent(\n        new CustomEvent(\"highlight-change\", {\n          detail: { itemId: currentItemId },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  }\n\n  _resetHighlightAfterFilter() {\n    const items = this._getNavigableItems();\n    if (this._highlightedIndex >= items.length) {\n      this._highlightedIndex = Math.max(0, items.length - 1);\n    }\n    this._updateHighlighted(items);\n  }\n\n  override render() {\n    const command = this.closest(\"ui-command\") as Command;\n    const label = command?.label\n      ? `${command.label} options`\n      : \"Command menu options\";\n\n    return html`\n      <div\n        id=\"command-list\"\n        role=\"listbox\"\n        aria-label=${label}\n        class=${cn(\n          \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Item component\n */\n@customElement(\"ui-command-item\")\nexport class CommandItem\n  extends TW(LitElement)\n  implements CommandItemProperties\n{\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  @property({ type: String }) value = \"\";\n  @property({ type: String }) id = \"\";\n  @property({ attribute: false }) keywords: string[] = [];\n  @property({ type: Boolean }) disabled = false;\n  @property({ type: Boolean, attribute: \"force-mount\" }) forceMount = false;\n\n  @state() _score = 1;\n  @state() _highlighted = false;\n\n  // Public methods for external state updates\n  public updateScore(score: number): void {\n    this._score = score;\n  }\n\n  public setHighlighted(highlighted: boolean): void {\n    this._highlighted = highlighted;\n  }\n\n  public getScore(): number {\n    return this._score;\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Auto-generate ID if not provided\n    if (!this.id) {\n      this.id = `command-item-${Math.random().toString(36).substring(2, 11)}`;\n    }\n\n    const command = this.closest(\"ui-command\") as Command;\n    if (command) {\n      command.registerItem(this);\n    }\n\n    // Register with parent group if exists\n    const group = this.closest(\"ui-command-group\") as CommandGroup;\n    if (group) {\n      group.registerChildItem(this);\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    const command = this.closest(\"ui-command\") as Command;\n    if (command) {\n      command.unregisterItem(this);\n    }\n\n    // Unregister from parent group if exists\n    const group = this.closest(\"ui-command-group\") as CommandGroup;\n    if (group) {\n      group.unregisterChildItem(this);\n    }\n  }\n\n  override willUpdate(changedProperties: PropertyValues) {\n    super.willUpdate(changedProperties);\n    // Auto-infer value from textContent if not provided\n    if (!this.value && this.textContent) {\n      this.value = this.textContent.trim();\n    }\n  }\n\n  private _handleClick = () => {\n    if (this.disabled) return;\n\n    this.dispatchEvent(\n      new CustomEvent(\"command-item-select\", {\n        detail: { value: this.value, item: this },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  private _handleMouseEnter = () => {\n    if (!this.disabled && this.getScore() > 0) {\n      this.setHighlighted(true);\n      // Notify list to sync highlighted index\n      const list = this.closest(\"ui-command-list\") as CommandList;\n      if (list) {\n        list.syncHighlightToItem(this);\n      }\n    }\n  };\n\n  private _handleMouseLeave = () => {\n    this.setHighlighted(false);\n  };\n\n  override render() {\n    // Don't render if filtered out (unless forceMount)\n    if (!this.forceMount && this._score === 0) {\n      return nothing;\n    }\n\n    return html`\n      <div\n        id=${this.id}\n        role=\"option\"\n        aria-selected=${this._highlighted}\n        aria-disabled=${this.disabled}\n        class=${cn(\n          \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none\",\n          \"transition-colors duration-100\",\n          \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n          \"aria-[selected=false]:hover:bg-accent/50 aria-[selected=false]:hover:text-accent-foreground\",\n          \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n          \"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0\",\n          this.className,\n        )}\n        @click=${this._handleClick}\n        @mouseenter=${this._handleMouseEnter}\n        @mouseleave=${this._handleMouseLeave}\n      >\n        <slot></slot>\n        <slot name=\"shortcut\"></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Empty component\n */\n@customElement(\"ui-command-empty\")\nexport class CommandEmpty extends TW(LitElement) {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  @state() private _shouldShow = false;\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this._checkVisibility();\n\n    // Listen for filter update events on parent command\n    const command = this.closest(\"ui-command\");\n    if (command) {\n      command.addEventListener(\"filter-update\", this._handleFilterUpdate);\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n\n    // Remove listener from parent command\n    const command = this.closest(\"ui-command\");\n    if (command) {\n      command.removeEventListener(\"filter-update\", this._handleFilterUpdate);\n    }\n  }\n\n  private _handleFilterUpdate = () => {\n    this._checkVisibility();\n  };\n\n  private _checkVisibility() {\n    const command = this.closest(\"ui-command\") as Command;\n    if (command) {\n      // Show empty state if there's a search query but no results\n      const searchValue = command.getSearchValue();\n      const filteredCount = command.getFilteredCount();\n      this._shouldShow = searchValue.length > 0 && filteredCount === 0;\n    }\n  }\n\n  override render() {\n    if (!this._shouldShow) {\n      return nothing;\n    }\n\n    return html`\n      <div\n        role=\"status\"\n        aria-live=\"polite\"\n        class=${cn(\"py-6 text-center text-sm\", this.className)}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Group component\n */\n@customElement(\"ui-command-group\")\nexport class CommandGroup\n  extends TW(LitElement)\n  implements CommandGroupProperties\n{\n  @property({ type: String }) heading = \"\";\n  @property({ type: Boolean, attribute: \"force-mount\" }) forceMount = false;\n\n  @state() _hidden = false;\n  private _childItems = new Set<CommandItem>();\n\n  // Public methods for managing child items\n  public registerChildItem(item: CommandItem): void {\n    this._childItems.add(item);\n  }\n\n  public unregisterChildItem(item: CommandItem): void {\n    this._childItems.delete(item);\n  }\n\n  public hasVisibleItems(): boolean {\n    return Array.from(this._childItems).some((item) => item.getScore() > 0);\n  }\n\n  public setHidden(hidden: boolean): void {\n    this._hidden = hidden;\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n    const command = this.closest(\"ui-command\") as Command;\n    if (command) {\n      command.registerGroup(this);\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    const command = this.closest(\"ui-command\") as Command;\n    if (command) {\n      command.unregisterGroup(this);\n    }\n  }\n\n  override render() {\n    if (this._hidden && !this.forceMount) {\n      return nothing;\n    }\n\n    const headingId = this.heading\n      ? `group-heading-${this.id || Math.random().toString(36).substring(2, 11)}`\n      : \"\";\n\n    return html`\n      <div\n        role=\"group\"\n        aria-labelledby=${headingId || nothing}\n        class=${cn(\"overflow-hidden p-1 text-foreground\", this.className)}\n      >\n        ${\n          this.heading\n            ? html`\n              <div\n                id=${headingId}\n                role=\"presentation\"\n                class=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\"\n              >\n                ${this.heading}\n              </div>\n            `\n            : nothing\n        }\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Separator component\n */\n@customElement(\"ui-command-separator\")\nexport class CommandSeparator extends TW(LitElement) {\n  override render() {\n    return html`\n      <div\n        role=\"separator\"\n        aria-orientation=\"horizontal\"\n        class=${cn(\"-mx-1 h-px bg-border\", this.className)}\n      ></div>\n    `;\n  }\n}\n\n/**\n * Command Shortcut component\n */\n@customElement(\"ui-command-shortcut\")\nexport class CommandShortcut extends TW(LitElement) {\n  static styles = css`\n    :host {\n      display: inline;\n    }\n  `;\n\n  override render() {\n    return html`\n      <span\n        class=${cn(\n          \"ml-auto text-xs tracking-widest text-muted-foreground\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </span>\n    `;\n  }\n}\n\n/**\n * Command Loading component\n */\n@customElement(\"ui-command-loading\")\nexport class CommandLoading extends TW(LitElement) {\n  static styles = css`\n    :host {\n      display: block;\n    }\n  `;\n\n  override render() {\n    return html`\n      <div class=${cn(\"py-6 text-center text-sm\", this.className)}>\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Command Dialog component properties\n */\nexport interface CommandDialogProperties {\n  open?: boolean;\n  modal?: boolean;\n  shortcut?: string;\n  enableShortcut?: boolean;\n  closeOnSelect?: boolean;\n  shouldFilter?: boolean;\n  loop?: boolean;\n  placeholder?: string;\n}\n\n/**\n * Command Dialog - A command menu displayed in a dialog modal.\n * Commonly used for application-wide command palettes (like VS Code's Command Palette).\n * Keyboard shortcut defaults to Cmd/Ctrl+K.\n */\n@customElement(\"ui-command-dialog\")\nexport class CommandDialog\n  extends TW(LitElement)\n  implements CommandDialogProperties\n{\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  @property({ type: Boolean, reflect: true }) open = false;\n  @property({ type: Boolean }) modal = true;\n  /**\n   * Keyboard shortcut key (default: \"k\" with Cmd/Ctrl).\n   * Note: Cmd/Ctrl+K may conflict with browser shortcuts in some browsers.\n   * Consider using a different key or requiring additional modifiers.\n   */\n  @property({ type: String }) shortcut = \"k\";\n  @property({ type: Boolean, attribute: \"enable-shortcut\" })\n  enableShortcut = true;\n  @property({ type: Boolean, attribute: \"close-on-select\" })\n  closeOnSelect = true;\n  @property({ type: Boolean, attribute: \"should-filter\" }) shouldFilter = true;\n  @property({ type: Boolean }) loop = false;\n  @property({ type: String }) placeholder = \"Type a command or search...\";\n\n  private _keydownHandler?: (e: KeyboardEvent) => void;\n  private _isHandlerRegistered = false;\n\n  render() {\n    return html`\n      <ui-dialog\n        .open=${this.open}\n        .modal=${this.modal}\n        @open-change=${this._handleOpenChange}\n      >\n        <ui-dialog-content slot=\"content\" class=\"p-0 gap-0 max-w-[640px]\">\n          <ui-command\n            class=\"[&_[cmdk-input-wrapper]]:border-b [&_[cmdk-input-wrapper]]:border-b-border\"\n            .shouldFilter=${this.shouldFilter}\n            .loop=${this.loop}\n          >\n            <slot name=\"input\">\n              <ui-command-input\n                .placeholder=${this.placeholder}\n              ></ui-command-input>\n            </slot>\n\n            <ui-command-list>\n              <slot name=\"empty\">\n                <ui-command-empty>No results found.</ui-command-empty>\n              </slot>\n              <slot></slot>\n            </ui-command-list>\n          </ui-command>\n        </ui-dialog-content>\n      </ui-dialog>\n    `;\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    if (this.enableShortcut) {\n      this._registerKeyboardShortcut();\n    }\n\n    // Listen for select events from command (bubbles through slotted content)\n    this.addEventListener(\"select\", this._handleCommandSelect as EventListener);\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this._unregisterKeyboardShortcut();\n    this.removeEventListener(\n      \"select\",\n      this._handleCommandSelect as EventListener,\n    );\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"enableShortcut\")) {\n      if (this.enableShortcut) {\n        this._registerKeyboardShortcut();\n      } else {\n        this._unregisterKeyboardShortcut();\n      }\n    }\n\n    if (changedProperties.has(\"shortcut\")) {\n      // Re-register with new shortcut key\n      this._unregisterKeyboardShortcut();\n      if (this.enableShortcut) {\n        this._registerKeyboardShortcut();\n      }\n    }\n  }\n\n  private _registerKeyboardShortcut() {\n    // Prevent double registration\n    if (this._isHandlerRegistered) {\n      this._unregisterKeyboardShortcut();\n    }\n\n    this._keydownHandler = (e: KeyboardEvent) => {\n      // Check for Cmd (Mac) or Ctrl (Windows/Linux) + configured key\n      if (\n        (e.metaKey || e.ctrlKey) &&\n        e.key.toLowerCase() === this.shortcut.toLowerCase()\n      ) {\n        e.preventDefault();\n        this.open = !this.open;\n\n        this.dispatchEvent(\n          new CustomEvent(\"shortcut-triggered\", {\n            detail: {\n              key: this.shortcut,\n              modifiers: [e.metaKey ? \"meta\" : \"ctrl\"],\n            },\n            bubbles: true,\n            composed: true,\n          }),\n        );\n      }\n    };\n\n    document.addEventListener(\"keydown\", this._keydownHandler);\n    this._isHandlerRegistered = true;\n  }\n\n  private _unregisterKeyboardShortcut() {\n    if (this._keydownHandler && this._isHandlerRegistered) {\n      document.removeEventListener(\"keydown\", this._keydownHandler);\n      this._keydownHandler = undefined;\n      this._isHandlerRegistered = false;\n    }\n  }\n\n  private _handleOpenChange = (e: CustomEvent<{ open: boolean }>) => {\n    this.open = e.detail.open;\n\n    // Re-emit for external listeners\n    this.dispatchEvent(\n      new CustomEvent(\"open-change\", {\n        detail: { open: this.open },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  private _handleCommandSelect = (e: CustomEvent) => {\n    // Re-emit command-select event\n    this.dispatchEvent(\n      new CustomEvent(\"command-select\", {\n        detail: e.detail,\n        bubbles: true,\n        composed: true,\n      }),\n    );\n\n    // Auto-close if enabled\n    if (this.closeOnSelect) {\n      this.open = false;\n    }\n  };\n}\n\n// Event detail types\nexport interface CommandDialogOpenChangeEvent {\n  open: boolean;\n}\n\nexport interface CommandDialogShortcutEvent {\n  key: string;\n  modifiers: string[];\n}\n\n// Type declarations for TypeScript\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-command\": Command;\n    \"ui-command-input\": CommandInput;\n    \"ui-command-list\": CommandList;\n    \"ui-command-item\": CommandItem;\n    \"ui-command-empty\": CommandEmpty;\n    \"ui-command-group\": CommandGroup;\n    \"ui-command-separator\": CommandSeparator;\n    \"ui-command-shortcut\": CommandShortcut;\n    \"ui-command-loading\": CommandLoading;\n    \"ui-command-dialog\": CommandDialog;\n  }\n\n  interface HTMLElementEventMap {\n    \"shortcut-triggered\": CustomEvent<CommandDialogShortcutEvent>;\n  }\n}\n"},{"path":"registry/ui/command/command.stories.ts","type":"registry:ui","content":"import \"./command\";\nimport \"../dialog/dialog\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport { unsafeHTML } from \"lit/directives/unsafe-html.js\";\nimport {\n  Calculator,\n  Calendar,\n  CreditCard,\n  Settings,\n  Smile,\n  User,\n} from \"lucide-static\";\nimport type { CommandProperties } from \"./command\";\n\ntype CommandArgs = CommandProperties & {\n  className?: string;\n};\n\n/**\n * Fast, composable, unstyled command menu web component.\n * Features search/filtering, keyboard navigation, item grouping, and accessibility support.\n */\nconst meta: Meta<CommandArgs> = {\n  title: \"ui/Command\",\n  component: \"ui-command\",\n  tags: [\"autodocs\"],\n  argTypes: {\n    shouldFilter: {\n      control: \"boolean\",\n      description: \"Enable/disable automatic filtering\",\n      defaultValue: true,\n    },\n    loop: {\n      control: \"boolean\",\n      description: \"Wrap keyboard navigation at edges\",\n      defaultValue: false,\n    },\n    value: {\n      control: \"text\",\n      description: \"Currently selected item value\",\n    },\n    label: {\n      control: \"text\",\n      description: \"Accessible label for screen readers\",\n      defaultValue: \"Command Menu\",\n    },\n  },\n  args: {\n    className: \"rounded-lg w-96 border shadow-md\",\n    shouldFilter: true,\n    loop: false,\n  },\n  render: (args) => html`\n    <ui-command\n      class=${args.className || \"\"}\n      ?should-filter=${args.shouldFilter}\n      ?loop=${args.loop}\n      .value=${args.value || \"\"}\n      label=${args.label || \"Command Menu\"}\n    >\n      <ui-command-input\n        placeholder=\"Type a command or search...\"\n      ></ui-command-input>\n      <ui-command-list>\n        <ui-command-empty>No results found.</ui-command-empty>\n        <ui-command-group heading=\"Suggestions\">\n          <ui-command-item value=\"calendar\">Calendar</ui-command-item>\n          <ui-command-item value=\"emoji\">Search Emoji</ui-command-item>\n          <ui-command-item value=\"calculator\" disabled\n            >Calculator</ui-command-item\n          >\n        </ui-command-group>\n        <ui-command-separator></ui-command-separator>\n        <ui-command-group heading=\"Settings\">\n          <ui-command-item value=\"profile\">Profile</ui-command-item>\n          <ui-command-item value=\"billing\">Billing</ui-command-item>\n          <ui-command-item value=\"settings\">Settings</ui-command-item>\n        </ui-command-group>\n      </ui-command-list>\n    </ui-command>\n  `,\n  parameters: {\n    layout: \"centered\",\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj<CommandArgs>;\n\n/**\n * The default form of the command menu.\n */\nexport const Default: Story = {};\n\n/**\n * Command menu with keyboard shortcuts displayed.\n */\nexport const WithShortcuts: Story = {\n  render: (args) => html`\n    <ui-command\n      class=${args.className || \"\"}\n      ?should-filter=${args.shouldFilter}\n      ?loop=${args.loop}\n    >\n      <ui-command-input\n        placeholder=\"Type a command or search...\"\n      ></ui-command-input>\n      <ui-command-list>\n        <ui-command-empty>No results found.</ui-command-empty>\n        <ui-command-group heading=\"Suggestions\">\n          <ui-command-item value=\"calendar\">\n            ${unsafeHTML(Calendar)} Calendar\n          </ui-command-item>\n          <ui-command-item value=\"emoji\">\n            ${unsafeHTML(Smile)} Search Emoji\n          </ui-command-item>\n          <ui-command-item value=\"calculator\" disabled>\n            ${unsafeHTML(Calculator)} Calculator\n          </ui-command-item>\n        </ui-command-group>\n        <ui-command-separator></ui-command-separator>\n        <ui-command-group heading=\"Settings\">\n          <ui-command-item value=\"profile\">\n            ${unsafeHTML(User)} Profile\n            <ui-command-shortcut slot=\"shortcut\">⌘P</ui-command-shortcut>\n          </ui-command-item>\n          <ui-command-item value=\"billing\">\n            ${unsafeHTML(CreditCard)} Billing\n            <ui-command-shortcut slot=\"shortcut\">⌘B</ui-command-shortcut>\n          </ui-command-item>\n          <ui-command-item value=\"settings\">\n            ${unsafeHTML(Settings)} Settings\n            <ui-command-shortcut slot=\"shortcut\">⌘S</ui-command-shortcut>\n          </ui-command-item>\n        </ui-command-group>\n      </ui-command-list>\n    </ui-command>\n  `,\n};\n\n/**\n * Command menu with loading state.\n */\nexport const LoadingState: Story = {\n  render: (args) => html`\n    <ui-command class=${args.className || \"\"}>\n      <ui-command-input placeholder=\"Searching...\"></ui-command-input>\n      <ui-command-list>\n        <ui-command-loading>Fetching results...</ui-command-loading>\n      </ui-command-list>\n    </ui-command>\n  `,\n};\n\n/**\n * Command menu displayed in a dialog. Press Cmd+K (Mac) or Ctrl+K (Windows/Linux) to open.\n * This is commonly used for application-wide command palettes (like VS Code's Command Palette).\n */\nexport const Dialog: Story = {\n  render: () => html`\n    <div class=\"flex flex-col items-center gap-4\">\n      <p class=\"text-sm text-muted-foreground\">\n        Press\n        <kbd\n          class=\"pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100\"\n        >\n          <span class=\"text-xs\">⌘</span>K\n        </kbd>\n      </p>\n\n      <ui-command-dialog>\n        <ui-command-group heading=\"Suggestions\">\n          <ui-command-item value=\"calendar\">\n            ${unsafeHTML(Calendar)}\n            <span>Calendar</span>\n          </ui-command-item>\n          <ui-command-item value=\"emoji\">\n            ${unsafeHTML(Smile)}\n            <span>Search Emoji</span>\n          </ui-command-item>\n          <ui-command-item value=\"calculator\">\n            ${unsafeHTML(Calculator)}\n            <span>Calculator</span>\n          </ui-command-item>\n        </ui-command-group>\n\n        <ui-command-separator></ui-command-separator>\n\n        <ui-command-group heading=\"Settings\">\n          <ui-command-item value=\"profile\">\n            ${unsafeHTML(User)}\n            <span>Profile</span>\n            <ui-command-shortcut slot=\"shortcut\">⌘P</ui-command-shortcut>\n          </ui-command-item>\n          <ui-command-item value=\"billing\">\n            ${unsafeHTML(CreditCard)}\n            <span>Billing</span>\n            <ui-command-shortcut slot=\"shortcut\">⌘B</ui-command-shortcut>\n          </ui-command-item>\n          <ui-command-item value=\"settings\">\n            ${unsafeHTML(Settings)}\n            <span>Settings</span>\n            <ui-command-shortcut slot=\"shortcut\">⌘S</ui-command-shortcut>\n          </ui-command-item>\n        </ui-command-group>\n      </ui-command-dialog>\n    </div>\n  `,\n};\n\n/**\n * Command dialog in open state for testing and visual inspection.\n */\nexport const DialogOpen: Story = {\n  tags: [\"!dev\", \"!autodocs\"],\n  render: () => html`\n    <ui-command-dialog open>\n      <ui-command-group heading=\"Suggestions\">\n        <ui-command-item value=\"calendar\">\n          ${unsafeHTML(Calendar)}\n          <span>Calendar</span>\n        </ui-command-item>\n        <ui-command-item value=\"emoji\">\n          ${unsafeHTML(Smile)}\n          <span>Search Emoji</span>\n        </ui-command-item>\n        <ui-command-item value=\"calculator\">\n          ${unsafeHTML(Calculator)}\n          <span>Calculator</span>\n        </ui-command-item>\n      </ui-command-group>\n\n      <ui-command-separator></ui-command-separator>\n\n      <ui-command-group heading=\"Settings\">\n        <ui-command-item value=\"profile\">\n          ${unsafeHTML(User)}\n          <span>Profile</span>\n          <ui-command-shortcut slot=\"shortcut\">⌘P</ui-command-shortcut>\n        </ui-command-item>\n        <ui-command-item value=\"billing\">\n          ${unsafeHTML(CreditCard)}\n          <span>Billing</span>\n          <ui-command-shortcut slot=\"shortcut\">⌘B</ui-command-shortcut>\n        </ui-command-item>\n        <ui-command-item value=\"settings\">\n          ${unsafeHTML(Settings)}\n          <span>Settings</span>\n          <ui-command-shortcut slot=\"shortcut\">⌘S</ui-command-shortcut>\n        </ui-command-item>\n      </ui-command-group>\n    </ui-command-dialog>\n  `,\n};\n"}]}