{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-field",
  "title": "Search Field",
  "description": "A search input whose clear button pops in once there is text, whose ⌘K hint fades out on focus, and whose loading spinner sits in the icon slot so nothing shifts. Escape clears.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-field.json",
    "https://eased.dev/r/kbd.json",
    "https://eased.dev/r/spinner.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/search-field/search-field.tsx",
      "content": "\"use client\";\n\nimport { Input as BaseInput } from \"@base-ui/react/input\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentProps, type KeyboardEvent } from \"react\";\nimport { InputGroup, InputGroupAddon, controlClass, type ControlSize } from \"../_primitives/field/field\";\nimport { exitFast, fade, reducedFade, snappy, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../lib/motion\";\nimport { Kbd } from \"../kbd/kbd\";\nimport { modKey, useIsMac } from \"../lib/platform\";\nimport { pressIcon } from \"../lib/press\";\nimport { Spinner } from \"../spinner/spinner\";\n\nexport type SearchFieldProps = Omit<\n  ComponentProps<typeof BaseInput>,\n  \"size\" | \"className\" | \"type\" | \"value\" | \"defaultValue\" | \"onValueChange\"\n> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called after the clear button or Escape empties the field. */\n  onClear?: () => void;\n  /** Swap the magnifier for a spinner in the same slot, e.g. while results load. Nothing shifts. */\n  loading?: boolean;\n  /**\n   * The letter that focuses the field with ⌘ (Apple) or Ctrl (elsewhere), shown as a hint\n   * while the field is empty and unfocused. Default \"k\"; false for no shortcut.\n   */\n  shortcut?: string | false;\n  /** sm 32px, md 36px (default), lg 40px. */\n  size?: ControlSize;\n  /** Accessible name for the clear button. Default \"Clear search (Escape)\". */\n  clearLabel?: string;\n  /** Classes for the bordered shell. */\n  className?: string;\n  /** Classes for the `<input>` inside the shell. */\n  controlClassName?: string;\n};\n\n// One owner per shortcut letter: the first mounted field keeps it, later ones warn and stay quiet.\nconst owners = new Map<string, string[]>();\nconst ownerListeners = new Set<() => void>();\nconst emitOwners = () => ownerListeners.forEach((l) => l());\nconst subscribeOwners = (l: () => void) => {\n  ownerListeners.add(l);\n  return () => ownerListeners.delete(l);\n};\n\nfunction useShortcutOwner(shortcut: string | false) {\n  const id = useId();\n  const key = shortcut ? shortcut.toLowerCase() : \"\";\n  useEffect(() => {\n    if (!key) return;\n    const list = owners.get(key) ?? [];\n    if (list.length && process.env.NODE_ENV !== \"production\") {\n      console.warn(`SearchField: another field already owns the \"${key}\" shortcut; this one will not respond to it.`);\n    }\n    owners.set(key, [...list, id]);\n    emitOwners();\n    return () => {\n      const rest = (owners.get(key) ?? []).filter((x) => x !== id);\n      if (rest.length) owners.set(key, rest);\n      else owners.delete(key);\n      emitOwners();\n    };\n  }, [key, id]);\n  // Before it registers, a field owns the key if nobody else does, so the hint never flashes in.\n  return useSyncExternalStore(\n    subscribeOwners,\n    () => !!key && ((owners.get(key)?.[0] ?? id) === id),\n    () => !!key,\n  );\n}\n\nconst pop = { opacity: 0, scale: 0.6, filter: \"blur(2px)\" } as const;\nconst shown = { opacity: 1, scale: 1, filter: \"blur(0px)\" } as const;\n\n/**\n * A search input. The clear button pops in once there is text; the ⌘K hint fades out on\n * focus; while `loading`, a spinner takes the magnifier's slot so the text never moves.\n * Escape clears the text, or blurs the field when it is already empty.\n */\nexport function SearchField({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onClear,\n  loading = false,\n  shortcut = \"k\",\n  size = \"md\",\n  clearLabel = \"Clear search (Escape)\",\n  placeholder = \"Search…\",\n  className = \"\",\n  controlClassName = \"\",\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ...props\n}: SearchFieldProps) {\n  const reduced = useSafeReducedMotion();\n  const mac = useIsMac();\n  const ownsShortcut = useShortcutOwner(shortcut);\n  const input = useRef<HTMLInputElement>(null);\n  const [inner, setInner] = useState(defaultValue);\n  const [focused, setFocused] = useState(false);\n  const value = valueProp ?? inner;\n\n  const set = (next: string) => {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const clear = () => {\n    set(\"\");\n    onClear?.();\n    input.current?.focus();\n  };\n\n  useEffect(() => {\n    if (!shortcut || !ownsShortcut) return;\n    const onKey = (e: globalThis.KeyboardEvent) => {\n      if ((mac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === shortcut.toLowerCase()) {\n        e.preventDefault();\n        input.current?.focus();\n        input.current?.select();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, ownsShortcut, mac]);\n\n  const keyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(e as Parameters<NonNullable<typeof onKeyDown>>[0]);\n    if (e.key !== \"Escape\" || e.defaultPrevented) return;\n    e.preventDefault();\n    if (value) clear();\n    else input.current?.blur();\n  };\n\n  const trailing = value ? \"clear\" : shortcut && ownsShortcut && !focused ? \"hint\" : null;\n\n  return (\n    <InputGroup size={size} className={className}>\n      <InputGroupAddon className=\"relative size-4 justify-center\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={loading ? \"spinner\" : \"search\"}\n            className=\"inline-flex\"\n            initial={reduced ? { opacity: 0 } : swapIn}\n            animate={reduced ? { opacity: 1 } : swapShown}\n            exit={reduced ? { opacity: 0, transition: reducedFade } : { ...swapOut, transition: { ...swap, opacity: exitFast, filter: exitFast } }}\n            transition={reduced ? reducedFade : swap}\n          >\n            {loading ? <Spinner size=\"sm\" label={false} className=\"text-current\" /> : <SearchIcon />}\n          </motion.span>\n        </AnimatePresence>\n      </InputGroupAddon>\n\n      <BaseInput\n        {...props}\n        ref={input}\n        type=\"search\"\n        value={value}\n        onValueChange={set}\n        placeholder={placeholder}\n        autoComplete=\"off\"\n        aria-keyshortcuts={shortcut && ownsShortcut ? `${mac ? \"Meta\" : \"Control\"}+${shortcut.toUpperCase()}` : undefined}\n        aria-busy={loading || undefined}\n        onKeyDown={keyDown}\n        onFocus={(e) => {\n          setFocused(true);\n          onFocus?.(e);\n        }}\n        onBlur={(e) => {\n          setFocused(false);\n          onBlur?.(e);\n        }}\n        // The native WebKit clear button duplicates ours.\n        className={`${controlClass} [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none ${controlClassName}`}\n      />\n\n      <span className=\"relative -mr-1 inline-flex h-5 min-w-5 shrink-0 items-center justify-end\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {trailing === \"clear\" && (\n            <motion.button\n              key=\"clear\"\n              type=\"button\"\n              aria-label={clearLabel}\n              // Escape clears from the keyboard, so the button stays out of the tab order (as in\n              // macOS and Spectrum); a pointer press keeps focus in the input. No `hit-area`: the\n              // extended target would cover the end of the text.\n              tabIndex={-1}\n              onPointerDown={(e) => e.preventDefault()}\n              onClick={clear}\n              initial={reduced ? { opacity: 0 } : pop}\n              animate={reduced ? { opacity: 1 } : shown}\n              exit={reduced ? { opacity: 0, transition: reducedFade } : { ...pop, transition: exitFast }}\n              transition={reduced ? reducedFade : { ...snappy, opacity: fade, filter: fade }}\n              className={`inline-flex size-5 cursor-pointer items-center justify-center rounded-pill bg-surface-hover text-text-secondary hover-fine:bg-surface-active hover-fine:text-text ${pressIcon}`}\n            >\n              <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-3\">\n                <path d=\"m4.5 4.5 7 7m0-7-7 7\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n              </svg>\n            </motion.button>\n          )}\n          {trailing === \"hint\" && shortcut && (\n            <motion.span\n              key=\"hint\"\n              aria-hidden\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: exitFast }}\n              transition={reduced ? reducedFade : fade}\n              className=\"inline-flex\"\n            >\n              <Kbd keys={[modKey(mac), shortcut.toUpperCase()]} listen={false} />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </InputGroup>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden>\n      <circle cx=\"7.25\" cy=\"7.25\" r=\"4.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"m10.75 10.75 2.5 2.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/search-field/search-field.tsx"
    }
  ],
  "docs": "Import SearchField from components/eased/search-field/search-field. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}