{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-input",
  "title": "Search input",
  "author": "Tori Bryan",
  "description": "A search box with a Cmd+K shortcut and a clear button, and a compact trigger for a header whose search opens in a command menu.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/button.json",
    "https://fibo.toribryan.com/r/input-group.json",
    "https://fibo.toribryan.com/r/kbd.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/search-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { SearchIcon, XIcon } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\"\nimport { useFieldSize, type FieldSize } from \"@/lib/field-size\"\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { cn } from \"@/lib/utils\"\n\nconst noSubscription = () => () => {}\n\n// The platform never changes, so there is nothing to subscribe to. The\n// server snapshot keeps the first render hydration-safe.\nfunction useIsMac() {\n  return React.useSyncExternalStore(\n    noSubscription,\n    () => /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent),\n    () => false\n  )\n}\n\n// Mod+hotkey anywhere on the page, unless something nearer the key took it.\n// Listeners run in the order they were added, so with two searches on one\n// page the first mounted wins.\nfunction useHotkey(hotkey: string | null, onPress: () => void) {\n  const press = React.useEffectEvent(onPress)\n  React.useEffect(() => {\n    if (!hotkey) return\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.defaultPrevented) return\n      if (\n        event.key.toLowerCase() === hotkey.toLowerCase() &&\n        (event.metaKey || event.ctrlKey) &&\n        !event.altKey &&\n        !event.shiftKey\n      ) {\n        event.preventDefault()\n        press()\n      }\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => document.removeEventListener(\"keydown\", onKeyDown)\n  }, [hotkey])\n}\n\nfunction keyShortcuts(hotkey: string | null, mac: boolean) {\n  return hotkey\n    ? `${mac ? \"Meta\" : \"Control\"}+${hotkey.toUpperCase()}`\n    : undefined\n}\n\nfunction Shortcut({ hotkey, mac }: { hotkey: string; mac: boolean }) {\n  return (\n    // aria-keyshortcuts on the control already announces it.\n    <KbdGroup aria-hidden=\"true\" className=\"pointer-coarse:hidden\">\n      <Kbd>{mac ? \"⌘\" : \"Ctrl\"}</Kbd>\n      <Kbd>{hotkey.toUpperCase()}</Kbd>\n    </KbdGroup>\n  )\n}\n\ntype SearchInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"type\" | \"size\" | \"value\" | \"defaultValue\"\n> & {\n  /** `ghost` drops the border and fill, for a search at the top of a menu or dialog. */\n  variant?: \"default\" | \"ghost\"\n  /** Height of the field. Defaults to the size of the Field or FieldGroup around it. */\n  size?: FieldSize\n  /** Key that, with Cmd or Ctrl, focuses the field. `null` turns it off. */\n  hotkey?: string | null\n  /** The query, when controlled. */\n  value?: string\n  /** The query to start with, when uncontrolled. */\n  defaultValue?: string\n  /** Called with the new query on every change, including a clear. */\n  onValueChange?: (value: string) => void\n}\n\n/** A search box with a Cmd+K shortcut and a clear button. */\nfunction SearchInput({\n  variant = \"default\",\n  size,\n  hotkey = \"k\",\n  placeholder = \"Search…\",\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  onKeyDown,\n  disabled,\n  className,\n  ref,\n  ...props\n}: SearchInputProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const value = valueProp ?? uncontrolled\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const mac = useIsMac()\n\n  const setValue = (next: string) => {\n    setUncontrolled(next)\n    onValueChange?.(next)\n  }\n\n  const clear = () => {\n    setValue(\"\")\n    inputRef.current?.focus()\n  }\n\n  useHotkey(disabled ? null : hotkey, () => {\n    inputRef.current?.focus()\n    inputRef.current?.select()\n  })\n\n  return (\n    <InputGroup\n      variant={variant}\n      size={size}\n      data-slot=\"search-input\"\n      className={className}\n    >\n      <InputGroupAddon>\n        <SearchIcon aria-hidden=\"true\" />\n      </InputGroupAddon>\n      <InputGroupInput\n        ref={mergeRefs(inputRef, ref)}\n        aria-label=\"Search\"\n        aria-keyshortcuts={disabled ? undefined : keyShortcuts(hotkey, mac)}\n        placeholder={placeholder}\n        value={value}\n        disabled={disabled}\n        onChange={(event) => {\n          onChange?.(event)\n          setValue(event.target.value)\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (event.defaultPrevented || event.key !== \"Escape\") return\n          // Escape clears first, then leaves the field. Stopping it keeps\n          // a dialog around the field open while it only clears.\n          if (value) {\n            event.preventDefault()\n            event.stopPropagation()\n            setValue(\"\")\n          } else event.currentTarget.blur()\n        }}\n        // The clear button below replaces the browser's own.\n        className=\"[&::-webkit-search-cancel-button]:appearance-none\"\n        {...props}\n        type=\"search\"\n      />\n      {value ? (\n        <InputGroupAddon align=\"inline-end\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon-xs\"\n            aria-label=\"Clear search\"\n            data-slot=\"search-input-clear\"\n            onClick={clear}\n          >\n            <XIcon aria-hidden=\"true\" />\n          </Button>\n        </InputGroupAddon>\n      ) : hotkey && !disabled ? (\n        <InputGroupAddon align=\"inline-end\">\n          <Shortcut hotkey={hotkey} mac={mac} />\n        </InputGroupAddon>\n      ) : null}\n    </InputGroup>\n  )\n}\n\ntype SearchTriggerProps = Omit<\n  React.ComponentProps<typeof Button>,\n  \"size\" | \"variant\"\n> & {\n  /** Height, matching a Button of the same size. Defaults to the size of the Field or FieldGroup around it. */\n  size?: FieldSize\n  /** Key that, with Cmd or Ctrl, presses the button. `null` turns it off. */\n  hotkey?: string | null\n}\n\n/**\n * A compact button with a search icon and its shortcut, for a header whose\n * search opens elsewhere, such as in a Command menu.\n */\nfunction SearchTrigger({\n  size: sizeProp,\n  hotkey = \"k\",\n  disabled,\n  className,\n  ref,\n  ...props\n}: SearchTriggerProps) {\n  const size = useFieldSize(sizeProp)\n  const buttonRef = React.useRef<HTMLButtonElement>(null)\n  const mac = useIsMac()\n\n  useHotkey(disabled ? null : hotkey, () => buttonRef.current?.click())\n\n  return (\n    <Button\n      ref={mergeRefs(buttonRef, ref)}\n      variant=\"ghost\"\n      size={size}\n      disabled={disabled}\n      aria-label=\"Search\"\n      aria-keyshortcuts={disabled ? undefined : keyShortcuts(hotkey, mac)}\n      className={cn(\"gap-2 px-2 text-muted-foreground\", className)}\n      {...props}\n      // After the spread, so a Command menu that renders it as its trigger\n      // doesn't swap in its own slot.\n      data-slot=\"search-trigger\"\n      type=\"button\"\n    >\n      <SearchIcon aria-hidden=\"true\" />\n      {hotkey ? <Shortcut hotkey={hotkey} mac={mac} /> : null}\n    </Button>\n  )\n}\n\nexport { SearchInput, SearchTrigger }\nexport type { SearchInputProps, SearchTriggerProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/field-size.ts",
      "content": "import * as React from \"react\"\n\nexport type FieldSize = \"sm\" | \"default\"\n\n/**\n * The size a FieldGroup or Field sets for the controls inside it. It lives\n * here rather than in the Field part so a control can read it without\n * installing Field.\n */\nexport const FieldSizeContext = React.createContext<FieldSize | undefined>(\n  undefined\n)\n\n/** A control's own size wins, then the nearest Field's or FieldGroup's. */\nexport function useFieldSize(size?: FieldSize): FieldSize {\n  const inherited = React.useContext(FieldSizeContext)\n  return size ?? inherited ?? \"default\"\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "meta": {
    "tier": "base-components",
    "group": "Forms",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-forms-search-input--docs",
    "status": "new"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-forms-search-input--docs",
  "categories": [
    "base-components",
    "forms"
  ],
  "type": "registry:ui"
}