{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-field",
  "type": "registry:ui",
  "title": "Search Field",
  "description": "Search box whose shortcut hint turns into a clear, with a patient spinner.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/search-field.tsx",
      "content": "\"use client\";\nimport { Input as BaseInput } from \"@base-ui/react/input\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Loader, Search, X } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SearchFieldProps = Omit<React.ComponentProps<\"input\">, \"size\" | \"value\" | \"defaultValue\" | \"type\"> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called with the query when Enter is pressed. */\n  onSearch?: (query: string) => void;\n  /** Swaps the magnifier for a spinner. Waits 150ms before showing and holds 300ms once shown, so fast results never flash. */\n  loading?: boolean;\n  /** Focuses the field from anywhere: \"mod+k\" (⌘K on Mac, Ctrl K elsewhere) or a single key like \"/\" that never fires while typing. false for none. */\n  shortcut?: string | false;\n  /** Starts as a square icon button and springs open to full width; folds back when it loses focus empty. */\n  collapsible?: boolean;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Classes for the inner input; `className` styles the outer box. */\n  inputClassName?: string;\n};\n\nconst subscribe = () => () => {};\nconst isMacNow = () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\nfunction useIsMac() {\n  return useSyncExternalStore(subscribe, isMacNow, () => true);\n}\n\n/** Holds a busy flag back for `delay` ms and keeps it for at least `min` ms once shown. */\nexport function useDelayedBusy(busy: boolean, { delay = 150, min = 300 } = {}) {\n  const [shown, setShown] = useState(false);\n  const since = useRef(0);\n  useEffect(() => {\n    let t: number;\n    if (busy && !shown) {\n      t = window.setTimeout(() => {\n        since.current = performance.now();\n        setShown(true);\n      }, delay);\n    } else if (!busy && shown) {\n      const left = Math.max(0, min - (performance.now() - since.current));\n      t = window.setTimeout(() => setShown(false), left);\n    }\n    return () => window.clearTimeout(t);\n  }, [busy, shown, delay, min]);\n  return shown;\n}\n\nfunction parseShortcut(s: string) {\n  const parts = s.toLowerCase().split(\"+\");\n  return { mod: parts.includes(\"mod\"), shift: parts.includes(\"shift\"), key: parts[parts.length - 1] };\n}\n\nconst isTyping = (el: Element | null) =>\n  !!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement || (el as HTMLElement).isContentEditable);\n\nconst sizes = {\n  // Left padding centers the icon in the folded square, so opening never nudges it.\n  sm: { box: \"h-7 rounded-md pl-1.5 pr-1 gap-1.5 text-base sm:text-[12.5px]\", square: 28, icon: 14 },\n  md: { box: \"h-8 rounded-lg pl-[7px] pr-1.5 gap-2 text-base sm:text-[13px]\", square: 32, icon: 16 },\n  lg: { box: \"h-9 rounded-lg pl-[9px] pr-2 gap-2 text-base sm:text-[13px]\", square: 36, icon: 16 },\n};\n\nexport function SearchField({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onSearch,\n  loading = false,\n  shortcut = \"mod+k\",\n  collapsible = false,\n  size = \"md\",\n  placeholder = \"Search\",\n  disabled,\n  className,\n  inputClassName,\n  onKeyDown,\n  onBlur,\n  ref,\n  ...rest\n}: SearchFieldProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const mac = useIsMac();\n  const busy = useDelayedBusy(loading);\n  const inner = useRef<HTMLInputElement | null>(null);\n  const [open, setOpen] = useState(!collapsible || defaultValue !== \"\");\n  // Opening from the keyboard is instant; opening from a press gets the spring.\n  const [instant, setInstant] = useState(false);\n  const expanded = !collapsible || open || value !== \"\";\n  const s = sizes[size];\n\n  const setRef = useCallback(\n    (node: HTMLInputElement | null) => {\n      inner.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  useEffect(() => {\n    if (!shortcut || disabled) return;\n    const want = parseShortcut(shortcut);\n    const onKey = (e: KeyboardEvent) => {\n      if (e.defaultPrevented || e.repeat) return;\n      const mod = mac ? e.metaKey : e.ctrlKey;\n      if (e.key.toLowerCase() !== want.key || mod !== want.mod || e.shiftKey !== want.shift || e.altKey) return;\n      if (!want.mod && (isTyping(document.activeElement) || e.metaKey || e.ctrlKey)) return;\n      e.preventDefault();\n      setInstant(true);\n      setOpen(true);\n      // The field may still be mounting its open state; focus on the next frame.\n      requestAnimationFrame(() => {\n        inner.current?.focus();\n        inner.current?.select();\n      });\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, disabled, mac]);\n\n  const hint = shortcut\n    ? (() => {\n        const p = parseShortcut(shortcut);\n        const key = p.key.length === 1 ? p.key.toUpperCase() : p.key;\n        return `${p.mod ? (mac ? \"⌘\" : \"Ctrl \") : \"\"}${p.shift ? (mac ? \"⇧\" : \"Shift \") : \"\"}${key}`;\n      })()\n    : null;\n\n  const clear = () => {\n    setValue(\"\");\n    inner.current?.focus();\n  };\n\n  const showClear = value !== \"\" && !disabled;\n  const width = expanded ? \"100%\" : s.square;\n\n  return (\n    <div className={cn(\"flex w-full min-w-0\", collapsible && \"justify-end\")}>\n      <motion.div\n        data-slot=\"search-field\"\n        data-size={size}\n        data-state={expanded ? \"open\" : \"closed\"}\n        data-disabled={disabled || undefined}\n        initial={false}\n        animate={{ width }}\n        transition={instant || reduce ? { duration: 0 } : spring.snappy}\n        onAnimationComplete={() => setInstant(false)}\n        className={cn(\n          \"group/search relative flex min-w-0 items-center overflow-hidden border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow,background-color,scale] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n          \"data-[state=closed]:hover:bg-hover data-[state=closed]:active:scale-[0.94] data-[state=closed]:active:duration-75\",\n          \"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2\",\n          s.box,\n          !collapsible && \"w-full\",\n          className,\n        )}\n        onMouseDown={(e) => {\n          const t = e.target as HTMLElement;\n          if (t.closest(\"input, button\") || disabled) return;\n          e.preventDefault();\n          inner.current?.focus();\n        }}\n      >\n        {/* Magnifier and spinner share one box, so the text never moves when loading starts. */}\n        <span aria-hidden className=\"relative grid shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-focus-within/search:text-fg-2\" style={{ width: s.icon, height: s.icon }}>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={busy ? \"busy\" : \"idle\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {busy ? <Loader size={s.icon} className=\"animate-spin\" /> : <Search size={s.icon} />}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n        <BaseInput\n          ref={setRef}\n          type=\"search\"\n          value={value}\n          onValueChange={(next) => setValue(next)}\n          disabled={disabled}\n          placeholder={placeholder}\n          aria-label={rest[\"aria-label\"] ?? (rest[\"aria-labelledby\"] ? undefined : placeholder)}\n          aria-busy={loading || undefined}\n          enterKeyHint=\"search\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          tabIndex={expanded ? undefined : -1}\n          onKeyDown={(e) => {\n            onKeyDown?.(e);\n            if (e.defaultPrevented) return;\n            if (e.key === \"Escape\") {\n              // First Escape empties the field; the next one lets go of it.\n              if (e.currentTarget.value) {\n                e.preventDefault();\n                e.stopPropagation();\n                setValue(\"\");\n              } else {\n                e.currentTarget.blur();\n              }\n            } else if (e.key === \"Enter\" && !e.nativeEvent.isComposing) {\n              onSearch?.(e.currentTarget.value);\n            }\n          }}\n          onBlur={(e) => {\n            onBlur?.(e);\n            if (collapsible && !e.currentTarget.value) setOpen(false);\n          }}\n          className={cn(\n            \"h-full min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-fg-4 disabled:cursor-not-allowed\",\n            \"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none\",\n            \"transition-opacity duration-150 group-data-[state=closed]/search:pointer-events-none group-data-[state=closed]/search:opacity-0\",\n            inputClassName,\n          )}\n          {...rest}\n        />\n\n        {/* Hint and clear stack in one cell: the hint says how to get here, the clear says how to start over. */}\n        <span className=\"grid shrink-0 place-items-center justify-items-end transition-opacity duration-150 group-data-[state=closed]/search:opacity-0\">\n          {hint && (\n            <kbd\n              aria-hidden\n              data-state={showClear ? \"hidden\" : \"visible\"}\n              className={cn(\n                \"col-start-1 row-start-1 inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-3 pointer-coarse:hidden\",\n                \"transition-[opacity,scale,filter] duration-200 ease-out-expo\",\n                \"data-[state=hidden]:scale-75 data-[state=hidden]:opacity-0 data-[state=hidden]:blur-[2px] data-[state=hidden]:duration-100\",\n                \"motion-reduce:data-[state=hidden]:scale-100 motion-reduce:data-[state=hidden]:blur-none\",\n              )}\n            >\n              {hint}\n            </kbd>\n          )}\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label=\"Clear search\"\n            inert={!showClear}\n            data-state={showClear ? \"visible\" : \"hidden\"}\n            onClick={clear}\n            className={cn(\n              \"relative col-start-1 row-start-1 grid size-5 place-items-center rounded-[5px] text-fg-3 outline-none\",\n              \"before:absolute before:-inset-1.5 before:content-[''] pointer-coarse:before:-inset-3\",\n              \"hover:bg-hover hover:text-fg active:scale-[0.88]\",\n              \"transition-[opacity,scale,filter,background-color,color] duration-200 ease-out-expo\",\n              \"data-[state=hidden]:pointer-events-none data-[state=hidden]:scale-75 data-[state=hidden]:opacity-0 data-[state=hidden]:blur-[2px] data-[state=hidden]:duration-100\",\n              \"motion-reduce:data-[state=hidden]:scale-100 motion-reduce:data-[state=hidden]:blur-none\",\n            )}\n          >\n            <X size={size === \"sm\" ? 12 : 14} />\n          </button>\n        </span>\n\n        {collapsible && !expanded && (\n          // While folded, the whole square is one button that opens the field.\n          <button\n            type=\"button\"\n            aria-label={rest[\"aria-label\"] ?? (typeof placeholder === \"string\" ? placeholder : \"Search\")}\n            aria-expanded={false}\n            disabled={disabled}\n            onClick={() => {\n              setInstant(false);\n              setOpen(true);\n              requestAnimationFrame(() => inner.current?.focus());\n            }}\n            className=\"absolute inset-0 rounded-[inherit] outline-none focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\"\n          />\n        )}\n      </motion.div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/search-field"
}
