{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command",
  "type": "registry:component",
  "title": "Command",
  "description": "Finds and runs commands inline or in a native dialog. Filter by typing or supply ranked results; navigate with arrows and Enter.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/dialog.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/command.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\n\nimport { Dialog } from \"./dialog\";\n\nexport interface CommandItem {\n  /** Stable identity when different commands share a visible label. */\n  id?: string;\n  label: string;\n  description?: React.ReactNode;\n  hint?: React.ReactNode;\n  keywords?: string;\n  onSelect?: () => void;\n}\n\nexport interface CommandGroup {\n  label?: string;\n  items: CommandItem[];\n}\n\nexport interface CommandProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\"> {\n  groups: CommandGroup[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  inputLabel?: string;\n  /** Disable built-in filtering when the application supplies ranked results. */\n  shouldFilter?: boolean;\n  emptyLabel?: React.ReactNode;\n  onDone?: () => void;\n}\n\nconst styles = stylex.create({\n  root: {\n    display: \"flex\",\n    flexDirection: \"column\",\n    minWidth: 0,\n  },\n  palette: {\n    width: {\n      default: 480,\n      [mq.phone]: \"100%\",\n    },\n    maxWidth: {\n      default: \"90vw\",\n      [mq.phone]: \"100%\",\n    },\n    padding: 0,\n    overflow: \"hidden\",\n  },\n  input: {\n    boxSizing: \"border-box\",\n    width: \"100%\",\n    borderWidth: 0,\n    borderBottomWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: vlak.divider,\n    backgroundColor: \"transparent\",\n    fontFamily: \"inherit\",\n    fontSize: {\n      default: \"0.875rem\",\n      [mq.phone]: \"1rem\",\n    },\n    letterSpacing: \"-0.01em\",\n    color: vlak.ink,\n    paddingBlock: \"0.875rem\",\n    paddingInline: \"1rem\",\n    /* The palette clips at its edge, so the ring sits inside the field. */\n    outlineWidth: {\n      default: 0,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: \"none\",\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": -2,\n    },\n    minHeight: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    \"::placeholder\": {\n      color: vlak.gray,\n    },\n  },\n  list: {\n    maxHeight: \"20rem\",\n    overflowY: \"auto\",\n    padding: \"0.375rem\",\n  },\n  group: {\n    fontSize: {\n      default: \"0.6875rem\",\n      [mq.phone]: \"0.8125rem\",\n    },\n    fontWeight: 600,\n    letterSpacing: \"-0.01em\",\n    color: vlak.gray,\n    paddingTop: {\n      default: \"0.5rem\",\n      [mq.phone]: \"0.75rem\",\n    },\n    paddingInlineEnd: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    paddingBottom: {\n      default: \"0.25rem\",\n      [mq.phone]: \"0.375rem\",\n    },\n    paddingInlineStart: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n  },\n  item: {\n    boxSizing: \"border-box\",\n    display: \"flex\",\n    alignItems: \"center\",\n    justifyContent: \"space-between\",\n    gap: \"0.75rem\",\n    width: \"100%\",\n    textAlign: \"start\",\n    borderWidth: 0,\n    backgroundColor: \"transparent\",\n    fontFamily: \"inherit\",\n    fontSize: {\n      default: \"0.84375rem\",\n      [mq.phone]: vlak.controlFs,\n    },\n    letterSpacing: \"-0.01em\",\n    color: vlak.ink,\n    paddingBlock: {\n      default: \"0.5rem\",\n      [mq.phone]: \"0.75rem\",\n    },\n    paddingInline: {\n      default: \"0.625rem\",\n      [mq.phone]: \"0.875rem\",\n    },\n    borderRadius: {\n      default: vlak.radiusSm,\n      [mq.phone]: vlak.radiusSm,\n    },\n    cursor: \"pointer\",\n    minHeight: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n  },\n  itemActive: {\n    backgroundColor: vlak.dividerSubtle,\n  },\n  content: {\n    display: \"flex\",\n    flexDirection: \"column\",\n    gap: \"0.25rem\",\n    minWidth: 0,\n    flex: \"1 1 auto\",\n  },\n  description: {\n    display: \"block\",\n    fontSize: \"0.75rem\",\n    lineHeight: 1.4,\n    color: vlak.gray,\n    overflow: \"hidden\",\n    textOverflow: \"ellipsis\",\n    whiteSpace: \"nowrap\",\n  },\n  descriptionActive: {\n    color: vlak.ink,\n  },\n  hint: {\n    fontSize: \"0.6875rem\",\n    color: vlak.gray,\n  },\n  hintActive: {\n    color: vlak.ink,\n  },\n  empty: {\n    padding: {\n      default: \"1.25rem\",\n      [mq.phone]: \"1.5rem\",\n    },\n    textAlign: \"center\",\n    fontSize: {\n      default: \"0.8125rem\",\n      [mq.phone]: \"0.9375rem\",\n    },\n    color: vlak.gray,\n  },\n});\n\nconst PAGE = 10;\n\n/** Search, arrows, enter. The input keeps focus; the list is aria-activedescendant. */\nexport const Command = React.forwardRef<HTMLDivElement, CommandProps>(function Command({\n  groups,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  placeholder = \"Type a command or search…\",\n  inputLabel = \"Command\",\n  shouldFilter = true,\n  emptyLabel = \"Nothing found.\",\n  onDone,\n  className,\n  style,\n  ...props\n}, ref) {\n  const idBase = React.useId();\n  const inputId = `${idBase}-input`;\n  const listboxId = `${idBase}-listbox`;\n  const optionId = (index: number) => `${idBase}-opt-${index}`;\n  const activeRef = React.useRef<HTMLDivElement>(null);\n  const composing = React.useRef(false);\n  const [inner, setInner] = React.useState(defaultValue);\n  const query = value ?? inner;\n\n  const q = query.trim().toLowerCase();\n  const filtered = groups\n    .map((group) => ({\n      ...group,\n      items: shouldFilter ? group.items.filter(\n        (item) => item.label.toLowerCase().includes(q) || item.keywords?.toLowerCase().includes(q),\n      ) : group.items,\n    }))\n    .filter((group) => group.items.length > 0);\n  const flat = filtered.flatMap((group) => group.items);\n  const hasList = flat.length > 0;\n  // Compare displayed identities, not array references: callers may recreate\n  // their groups on every render while keyboard selection should stay put.\n  const selectionKey = JSON.stringify([query, filtered.map(group => [group.label, group.items.map(item => item.id ?? item.label)])]);\n  const [selection, setSelection] = React.useState({ key: selectionKey, index: 0 });\n  if (selection.key !== selectionKey) {\n    // Persist the reset so returning to an earlier query cannot revive its selection.\n    setSelection({ key: selectionKey, index: 0 });\n  }\n  const activeIndex = selection.key === selectionKey ? Math.max(0, Math.min(flat.length - 1, selection.index)) : 0;\n  const setActiveIndex = (next: React.SetStateAction<number>) => setSelection(current => {\n    const index = current.key === selectionKey ? Math.max(0, Math.min(flat.length - 1, current.index)) : 0;\n    return { key: selectionKey, index: typeof next === \"function\" ? next(index) : next };\n  });\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: Scroll the current option after selection or displayed results change.\n  React.useEffect(() => {\n    activeRef.current?.scrollIntoView?.({ block: \"nearest\" });\n  }, [activeIndex, selectionKey]);\n\n  const run = (item: CommandItem) => {\n    onDone?.();\n    item.onSelect?.();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (composing.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;\n    const last = Math.max(0, flat.length - 1);\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        setActiveIndex(index => Math.min(last, index + 1));\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        setActiveIndex(index => Math.max(0, index - 1));\n        return;\n      case \"Home\":\n        e.preventDefault();\n        setActiveIndex(0);\n        return;\n      case \"End\":\n        e.preventDefault();\n        setActiveIndex(last);\n        return;\n      case \"PageUp\":\n        e.preventDefault();\n        setActiveIndex(index => Math.max(0, index - PAGE));\n        return;\n      case \"PageDown\":\n        e.preventDefault();\n        setActiveIndex(index => Math.min(last, index + PAGE));\n        return;\n      case \"Enter\": {\n        e.preventDefault();\n        const item = flat[activeIndex];\n        if (item) run(item);\n        return;\n      }\n      case \"Escape\":\n        e.preventDefault();\n        onDone?.();\n        return;\n      default:\n        return;\n    }\n  };\n\n  const root = rs([\"rs-command\", className], styles.root);\n  const input = rs([\"rs-command-input\"], styles.input);\n  const list = rs([\"rs-command-list\"], styles.list);\n  const empty = rs([\"rs-command-empty\"], styles.empty);\n  const groupSx = rs([\"rs-command-group\"], styles.group);\n  const content = rs([\"rs-command-content\"], styles.content);\n\n  let cursor = -1;\n  return (\n    <div ref={ref} {...props} className={root.className} style={{ ...root.style, ...style }}>\n      <input\n        id={inputId}\n        className={input.className}\n        style={input.style}\n        autoFocus\n        type=\"text\"\n        autoComplete=\"off\"\n        placeholder={placeholder}\n        role=\"combobox\"\n        aria-expanded={hasList}\n        aria-controls={hasList ? listboxId : undefined}\n        aria-activedescendant={hasList && flat[activeIndex] ? optionId(activeIndex) : undefined}\n        aria-autocomplete=\"list\"\n        aria-label={inputLabel}\n        value={query}\n        onChange={(e) => {\n          if (value === undefined) setInner(e.target.value);\n          onValueChange?.(e.target.value);\n        }}\n        onCompositionStart={() => { composing.current = true; }}\n        onCompositionEnd={() => { composing.current = false; }}\n        onKeyDown={onKeyDown}\n      />\n      <div\n        id={hasList ? listboxId : undefined}\n        role={hasList ? \"listbox\" : undefined}\n        aria-labelledby={hasList ? inputId : undefined}\n        tabIndex={-1}\n        className={list.className}\n        style={list.style}\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {flat.length === 0 && (\n          <div className={empty.className} style={empty.style}>\n            {emptyLabel}\n          </div>\n        )}\n        {filtered.map((group, gi) => {\n          const groupLabelId = `${idBase}-group-${gi}`;\n          return (\n            <div key={gi} role=\"group\" aria-labelledby={group.label ? groupLabelId : undefined}>\n              {group.label && (\n                <div id={groupLabelId} role=\"presentation\" className={groupSx.className} style={groupSx.style}>\n                  {group.label}\n                </div>\n              )}\n              {group.items.map((item) => {\n                cursor++;\n                const index = cursor;\n                const active = index === activeIndex;\n                const labelId = `${optionId(index)}-label`;\n                const descriptionId = `${optionId(index)}-description`;\n                const hintId = `${optionId(index)}-hint`;\n                const hasDescription = item.description != null;\n                const row = rs(\n                  [\"rs-command-item\", active && \"rs-command-item-active\"],\n                  styles.item,\n                  active && styles.itemActive,\n                );\n                const description = rs(\n                  [\"rs-command-description\", active && \"rs-command-description-active\"],\n                  styles.description,\n                  active && styles.descriptionActive,\n                );\n                const hint = rs(\n                  [\"rs-command-hint\", active && \"rs-command-hint-active\"],\n                  styles.hint,\n                  active && styles.hintActive,\n                );\n                return (\n                  <div\n                    key={item.id ?? `${gi}-${item.label}`}\n                    id={optionId(index)}\n                    ref={active ? activeRef : undefined}\n                    role=\"option\"\n                    tabIndex={-1}\n                    aria-selected={active}\n                    aria-labelledby={hasDescription ? labelId : undefined}\n                    aria-describedby={hasDescription ? [descriptionId, item.hint != null && hintId].filter(Boolean).join(\" \") : undefined}\n                    className={row.className}\n                    style={row.style}\n                    onPointerEnter={() => setActiveIndex(index)}\n                    onClick={() => run(item)}\n                  >\n                    <span className={content.className} style={content.style}>\n                      <span id={labelId}>{item.label}</span>\n                      {hasDescription && <span id={descriptionId} className={description.className} style={description.style}>{item.description}</span>}\n                    </span>\n                    {item.hint != null && (\n                      <span id={hintId} className={hint.className} style={hint.style}>\n                        {item.hint}\n                      </span>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n});\n\nexport interface CommandDialogProps extends CommandProps {\n  open: boolean;\n  onClose?: () => void;\n}\n\n/** The palette in a native <dialog>. Wire ⌘K in your app to setOpen(true). */\nexport const CommandDialog = React.forwardRef<HTMLDialogElement, CommandDialogProps>(function CommandDialog(\n  { open, onClose, className, \"aria-label\": ariaLabel = \"Commands\", \"aria-labelledby\": ariaLabelledBy, ...props },\n  ref,\n) {\n  return (\n    <Dialog\n      ref={ref}\n      open={open}\n      onClose={onClose}\n      aria-label={ariaLabelledBy ? undefined : ariaLabel}\n      aria-labelledby={ariaLabelledBy}\n      className={[\"rs-command-dialog\", className].filter(Boolean).join(\" \")}\n      extraStyles={[styles.palette]}\n    >\n      {open && <Command onDone={onClose} {...props} />}\n    </Dialog>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/command.tsx"
    },
    {
      "path": "vlak/styles/command.css",
      "content": "/* ── command: generated from packages/react/src/components/command.tsx ── */\n.rs-command{display:flex;flex-direction:column;min-width:0}\n.rs-command-dialog{width:480px;max-width:90vw;padding:0;overflow:hidden}\n@media (max-width: 640px){.rs-command-dialog{width:100%;max-width:100%}}\n.rs-command-input{box-sizing:border-box;width:100%;border-width:0;border-bottom-width:1px;border-style:solid;border-color:var(--divider);background-color:transparent;font-family:inherit;font-size:0.875rem;letter-spacing:-0.01em;color:var(--text);padding-block:0.875rem;padding-inline:1rem;outline-width:0;outline-style:none;min-height:var(--hit)}\n.rs-command-input:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:-2px}\n.rs-command-input::placeholder{color:var(--text-secondary)}\n@media (max-width: 640px){.rs-command-input{font-size:1rem;min-height:var(--hit)}}\n.rs-command-list{max-height:20rem;overflow-y:auto;padding:0.375rem}\n.rs-command-group{font-size:0.6875rem;font-weight:600;letter-spacing:-0.01em;color:var(--text-secondary);padding-top:0.5rem;padding-inline-end:0.625rem;padding-bottom:0.25rem;padding-inline-start:0.625rem}\n@media (max-width: 640px){.rs-command-group{font-size:0.8125rem;padding-top:0.75rem;padding-inline-end:0.875rem;padding-bottom:0.375rem;padding-inline-start:0.875rem}}\n.rs-command-item{box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:0.75rem;width:100%;text-align:start;border-width:0;background-color:transparent;font-family:inherit;font-size:0.84375rem;letter-spacing:-0.01em;color:var(--text);padding-block:0.5rem;padding-inline:0.625rem;border-radius:var(--radius-sm);cursor:pointer;min-height:var(--hit)}\n@media (max-width: 640px){.rs-command-item{font-size:var(--control-fs);padding-block:0.75rem;padding-inline:0.875rem;border-radius:var(--radius-sm);min-height:var(--hit)}}\n.rs-command-item-active{background-color:var(--divider-subtle)}\n.rs-command-content{display:flex;flex-direction:column;gap:0.25rem;min-width:0;flex:1 1 auto}\n.rs-command-description{display:block;font-size:0.75rem;line-height:1.4;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.rs-command-description-active{color:var(--text)}\n.rs-command-hint{font-size:0.6875rem;color:var(--text-secondary)}\n.rs-command-hint-active{color:var(--text)}\n.rs-command-empty{padding:1.25rem;text-align:center;font-size:0.8125rem;color:var(--text-secondary)}\n@media (max-width: 640px){.rs-command-empty{padding:1.5rem;font-size:0.9375rem}}\n",
      "type": "registry:file",
      "target": "styles/vlak/command.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "actions",
      "classes": [
        "rs-command",
        "rs-command-input",
        "rs-command-list",
        "rs-command-group",
        "rs-command-item",
        "rs-command-item-active",
        "rs-command-content",
        "rs-command-description",
        "rs-command-description-active",
        "rs-command-hint",
        "rs-command-hint-active",
        "rs-command-empty"
      ],
      "snippet": "<div class=\"rs-command\" style=\"border:1px solid var(--divider);border-radius:var(--radius)\"><input class=\"rs-command-input\" placeholder=\"Type a command or search…\" /><div class=\"rs-command-list\" role=\"listbox\"><div class=\"rs-command-group\">Go to</div><div class=\"rs-command-item rs-command-item-active\" role=\"option\" aria-selected=\"true\"><span>Components</span><span class=\"rs-command-hint rs-command-hint-active\">⌘1</span></div><div class=\"rs-command-item\" role=\"option\" aria-selected=\"false\"><span>Tokens</span><span class=\"rs-command-hint\">⌘2</span></div></div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "dialog",
        "vlak-lib"
      ],
      "aliases": [
        "Command",
        "Command palette",
        "cmdk",
        "Command menu",
        "Spotlight"
      ],
      "example": "import { useEffect, useState } from \"react\";\nimport { CommandDialog } from \"@noorddev/vlak-react\";\n\nconst [open, setOpen] = useState(false);\n\n// wire the shortcut once in your app\nuseEffect(() => {\n  const onKey = (e: KeyboardEvent) => {\n    if (e.key === \"k\" && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      setOpen(true);\n    }\n  };\n  window.addEventListener(\"keydown\", onKey);\n  return () => window.removeEventListener(\"keydown\", onKey);\n}, []);\n\n<CommandDialog\n  open={open}\n  onClose={() => setOpen(false)}\n  groups={[\n    { label: \"Go to\", items: [{ label: \"Components\", description: \"Browse components and usage\", hint: \"⌘1\", onSelect: () => go(\"/components\") }] },\n    { label: \"Actions\", items: [{ label: \"New project\", keywords: \"create add\", onSelect: create }] },\n  ]}\n/>",
      "usage": {
        "use": [
          "A palette of commands and destinations behind one shortcut.",
          "keywords on an item to widen the match; hint for the shortcut label and description for one concise secondary line.",
          "Supply a stable item id when commands share a visible label; it identifies rows and changes in result order.",
          "Control the query with value and onValueChange, or initialize it with defaultValue. Set inputLabel to name the search field.",
          "Set shouldFilter={false} when the application supplies ranked groups. Their order is preserved; query and result changes reset the active option."
        ],
        "avoid": [
          "Picking a value for a field; use Combobox.",
          "Fewer than ten commands; use DropdownMenu."
        ]
      },
      "keyboard": [
        {
          "keys": "Type",
          "does": "Updates the search; filters labels and keywords unless shouldFilter is false"
        },
        {
          "keys": "Arrow down, Arrow up",
          "does": "Moves the active item"
        },
        {
          "keys": "Home, End",
          "does": "First or last item"
        },
        {
          "keys": "Page up, Page down",
          "does": "Moves ten items"
        },
        {
          "keys": "Enter",
          "does": "Runs the active item and closes, except while composing text with an IME"
        },
        {
          "keys": "Escape",
          "does": "Closes"
        }
      ],
      "a11y": [
        "The input is role=\"combobox\" named by inputLabel (default \"Command\") with aria-autocomplete=\"list\" and aria-activedescendant; the list is role=\"listbox\" with labelled role=\"group\" sections.",
        "Item descriptions use aria-describedby, keeping the option label as its accessible name. Descriptions are visually limited to one line; their full text remains available to assistive technology.",
        "CommandDialog places it in a native modal <dialog>; Command alone renders inline. The input takes focus on mount."
      ]
    }
  }
}
