{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-field",
  "title": "Search Field",
  "description": "A search box with a leading icon, clear button, loading state, debounced onSearch and a Mod K hint. Escape clears it.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json",
    "https://miseui.junsu4621.workers.dev/r/kbd.json",
    "https://miseui.junsu4621.workers.dev/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/search-field.tsx",
      "content": "import type * as React from \"react\"\nimport { useImperativeHandle, useRef } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Search, X } from \"lucide-react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { Kbd, KbdGroup, Mod } from \"@/registry/miseui/ui/kbd\"\nimport { DEFAULT_DEBOUNCE_MS, useSearchFieldState } from \"@/registry/miseui/ui/search-field-state\"\nimport { Spinner } from \"@/registry/miseui/ui/spinner\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype SearchFieldVariant = \"plain\" | \"pill\" | \"ghost\" | \"large\"\ntype SearchFieldEffect = \"spinner-swap\"\n\nconst SWAP_SCALE = 0.6\n\nconst FRAME_BASE_CLASS = [\n  \"group/search flex w-full min-w-0 items-center gap-2 pl-3\",\n  \"transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-mise)\",\n  \"has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50\",\n]\n\nconst FRAME_CLASS: Record<SearchFieldVariant, string> = {\n  plain:\n    \"h-9 rounded-md border border-input bg-transparent pr-1 shadow-xs pointer-coarse:h-10 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50\",\n  // Sinks into the page until it is used, then lifts onto the surface.\n  pill: \"h-9 rounded-full bg-muted pr-1 pointer-coarse:h-10 has-[input:focus-visible]:bg-background has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50\",\n  // Only a hairline underneath: this one lives in toolbars and list headers that already have a frame.\n  ghost: \"h-9 rounded-none border-b border-transparent pl-0 pr-0 pointer-coarse:h-10 hover:border-border has-[input:focus-visible]:border-foreground\",\n  large:\n    \"h-12 gap-3 rounded-lg border border-input bg-card pl-4 pr-2 shadow-sm has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50\",\n}\n\nconst INPUT_CLASS: Record<SearchFieldVariant, string> = {\n  plain: \"text-base md:text-sm\",\n  pill: \"text-base md:text-sm\",\n  ghost: \"text-base md:text-sm\",\n  large: \"text-base md:text-[15px]\",\n}\n\nconst CLEAR_CLASS = [\n  \"inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground pointer-coarse:size-10\",\n  \"transition-[color,background-color] duration-(--duration-fast) ease-(--ease-mise)\",\n  \"outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n  \"active:bg-accent active:duration-0\",\n]\n\nconst ICON_SIZE: Record<SearchFieldVariant, string> = { plain: \"size-4\", pill: \"size-4\", ghost: \"size-4\", large: \"size-5\" }\n\ntype SearchFieldProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"type\" | \"size\"> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** Fires after typing stops, at once on Enter and on clear. Never twice for the same text. */\n  onSearch?: (query: string) => void\n  debounceMs?: number\n  /** Results are on their way. */\n  loading?: boolean\n  /** Read to assistive tech while loading. */\n  loadingText?: string\n  /** Shows the Mod K hint and listens for the shortcut anywhere on the page. Leave off when two fields share a page. */\n  shortcut?: boolean\n  clearLabel?: string\n  /** `plain` a field, `pill` a filled capsule, `ghost` no frame until used, `large` the main thing on a page. */\n  variant?: SearchFieldVariant\n  /** `spinner-swap` turns the leading magnifier into the spinner in place. Without it the spinner sits at the trailing edge. */\n  effect?: SearchFieldEffect\n}\n\nfunction LeadingIcon({ variant, swap, reduceMotion }: { variant: SearchFieldVariant; swap: boolean; reduceMotion: boolean }) {\n  const size = ICON_SIZE[variant]\n  const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: SWAP_SCALE }\n  return (\n    <span aria-hidden=\"true\" data-slot=\"search-field-icon\" className={cn(\"relative flex shrink-0 text-muted-foreground\", size)}>\n      {/* Both glyphs stack in one box, so the swap can never nudge the text beside them. */}\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={swap ? \"spinner\" : \"search\"}\n          className=\"absolute inset-0 flex items-center justify-center\"\n          initial={hidden}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={hidden}\n          transition={SPRING_DEFAULT}\n        >\n          {swap ? <Spinner size=\"sm\" className={size} label=\"\" /> : <Search className={size} />}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  )\n}\n\nfunction ShortcutHint() {\n  return (\n    <span aria-hidden=\"true\" data-slot=\"search-field-hint\" className=\"mr-1 shrink-0 group-has-[input:focus]/search:hidden pointer-coarse:hidden\">\n      <KbdGroup>\n        <Mod variant=\"flat\" />\n        <Kbd variant=\"flat\">K</Kbd>\n      </KbdGroup>\n    </span>\n  )\n}\n\nfunction SearchField({ value, defaultValue, onValueChange, onSearch, debounceMs = DEFAULT_DEBOUNCE_MS, loading = false, loadingText = \"Searching\", shortcut = false, clearLabel = \"Clear search\", variant = \"plain\", effect, className, ref, placeholder = \"Search\", disabled, onKeyDown, \"aria-label\": ariaLabel, ...props }: SearchFieldProps) {\n  const inputRef = useRef<HTMLInputElement>(null)\n  useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)\n  const reduceMotion = useReducedMotion() ?? false\n  const state = useSearchFieldState({ value, defaultValue, onValueChange, onSearch, debounceMs, shortcut, inputRef })\n  const swap = effect === \"spinner-swap\" && loading\n  const showClear = state.current !== \"\" && !disabled\n  return (\n    <div role=\"search\" data-slot=\"search-field\" data-variant={variant} data-effect={effect} data-loading={loading ? \"true\" : \"false\"} className={cn(FRAME_BASE_CLASS, FRAME_CLASS[variant], className)}>\n      <LeadingIcon variant={variant} swap={swap} reduceMotion={reduceMotion} />\n      <input\n        {...props}\n        {...state.composition}\n        ref={inputRef}\n        type=\"search\"\n        value={state.current}\n        disabled={disabled}\n        placeholder={placeholder}\n        aria-label={ariaLabel ?? placeholder}\n        aria-busy={loading || undefined}\n        autoComplete=\"off\"\n        // The browser's own clear button would sit beside ours in WebKit.\n        className={cn(\"h-full min-w-24 flex-1 bg-transparent outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none\", INPUT_CLASS[variant])}\n        onChange={(event) => state.change(event.target.value)}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (!event.defaultPrevented) state.onKeyDown(event)\n        }}\n      />\n      {loading && !swap ? (\n        <span aria-hidden=\"true\" data-slot=\"search-field-spinner\" className=\"mr-1 flex shrink-0\">\n          <Spinner size=\"sm\" label=\"\" />\n        </span>\n      ) : null}\n      {shortcut && !showClear ? <ShortcutHint /> : null}\n      {showClear ? (\n        <button type=\"button\" aria-label={clearLabel} data-slot=\"search-field-clear\" className={cn(CLEAR_CLASS)} onClick={state.clear}>\n          <X aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      ) : null}\n      <span role=\"status\" className=\"sr-only\">\n        {loading ? loadingText : \"\"}\n      </span>\n    </div>\n  )\n}\n\nexport { SearchField, type SearchFieldProps, type SearchFieldVariant, type SearchFieldEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/search-field-state.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\n\n/** Long enough to skip the keys of a word typed in one go, short enough that results still feel like an answer. */\nconst DEFAULT_DEBOUNCE_MS = 250\n\n/** Mod K: Command on Apple devices, Control everywhere else. */\nfunction isFocusShortcut(event: Pick<KeyboardEvent, \"key\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">) {\n  return event.key.toLowerCase() === \"k\" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey\n}\n\ninterface SearchFieldStateOptions {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onSearch?: (query: string) => void\n  debounceMs: number\n  shortcut: boolean\n  inputRef: React.RefObject<HTMLInputElement | null>\n}\n\nfunction useShortcut(enabled: boolean, inputRef: React.RefObject<HTMLInputElement | null>) {\n  useEffect(() => {\n    if (!enabled) return\n    const onKey = (event: KeyboardEvent) => {\n      if (!isFocusShortcut(event)) return\n      // The browser would otherwise take this for its own address bar search.\n      event.preventDefault()\n      inputRef.current?.focus()\n      inputRef.current?.select()\n    }\n    window.addEventListener(\"keydown\", onKey)\n    return () => window.removeEventListener(\"keydown\", onKey)\n  }, [enabled, inputRef])\n}\n\n/**\n * On the window in the capture phase: Radix dialogs and popovers listen on the document in capture, so a handler on\n * the input runs too late to keep the surrounding overlay from closing on the same press.\n */\nfunction useEscapeClears(hasText: boolean, inputRef: React.RefObject<HTMLInputElement | null>, clear: () => void) {\n  const latest = useRef(clear)\n  useEffect(() => {\n    latest.current = clear\n  })\n  useEffect(() => {\n    if (!hasText) return\n    const onEscape = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.isComposing || event.target !== inputRef.current) return\n      event.stopPropagation()\n      event.preventDefault()\n      latest.current()\n    }\n    window.addEventListener(\"keydown\", onEscape, true)\n    return () => window.removeEventListener(\"keydown\", onEscape, true)\n  }, [hasText, inputRef])\n}\n\nfunction useDebouncedSearch(onSearch: ((query: string) => void) | undefined, debounceMs: number) {\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const composing = useRef(false)\n  const lastSent = useRef<string | null>(null)\n  const search = useRef(onSearch)\n  useEffect(() => {\n    search.current = onSearch\n  }, [onSearch])\n  const cancel = () => {\n    if (timer.current !== null) clearTimeout(timer.current)\n    timer.current = null\n  }\n  useEffect(() => cancel, [])\n  const send = (query: string) => {\n    cancel()\n    if (lastSent.current === query) return\n    lastSent.current = query\n    search.current?.(query)\n  }\n  const schedule = (query: string) => {\n    cancel()\n    // Half-typed syllables from an input method are not a query yet; compositionend schedules the finished text.\n    if (composing.current) return\n    timer.current = setTimeout(() => send(query), debounceMs)\n  }\n  const composition = {\n    onCompositionStart: () => {\n      composing.current = true\n      cancel()\n    },\n    onCompositionEnd: (event: React.CompositionEvent<HTMLInputElement>) => {\n      composing.current = false\n      schedule(event.currentTarget.value)\n    },\n  }\n  return { send, schedule, composition }\n}\n\nfunction useSearchFieldState({ value, defaultValue = \"\", onValueChange, onSearch, debounceMs, shortcut, inputRef }: SearchFieldStateOptions) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue)\n  const current = value ?? uncontrolled\n  const { send, schedule, composition } = useDebouncedSearch(onSearch, debounceMs)\n  useShortcut(shortcut, inputRef)\n  const change = (next: string) => {\n    setUncontrolled(next)\n    onValueChange?.(next)\n    schedule(next)\n  }\n  const clear = () => {\n    setUncontrolled(\"\")\n    onValueChange?.(\"\")\n    send(\"\")\n    inputRef.current?.focus()\n  }\n  useEscapeClears(current !== \"\", inputRef, clear)\n  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    // Enter belongs to the input method while it is composing.\n    if (!event.nativeEvent.isComposing && event.key === \"Enter\") send(event.currentTarget.value)\n  }\n  return { current, change, clear, onKeyDown, composition }\n}\n\nexport { DEFAULT_DEBOUNCE_MS, isFocusShortcut, useSearchFieldState }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}