{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "search-input",
  "title": "Search Input",
  "author": "Matthew Blode",
  "description": "A search field with a clear button and an optional cycling placeholder.",
  "dependencies": ["blode-icons-react"],
  "registryDependencies": ["input-group"],
  "files": [
    {
      "path": "ui/search-input.tsx",
      "content": "\"use client\";\n\nimport { SearchIcon, XIcon } from \"blode-icons-react\";\nimport type * as React from \"react\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\n\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupInput,\n} from \"@/components/ui/input-group\";\n\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\n\n/** Joins the placeholder list into one dependency so a fresh array literal on\n * every render does not restart the typewriter. */\nconst PHRASE_SEPARATOR = \"\\n\";\n\nconst subscribeToReducedMotion = (onStoreChange: () => void) => {\n  const query = window.matchMedia(REDUCED_MOTION_QUERY);\n  query.addEventListener(\"change\", onStoreChange);\n  return () => query.removeEventListener(\"change\", onStoreChange);\n};\n\n/**\n * Tracks `prefers-reduced-motion` and re-renders when it changes. The global\n * reduced-motion stylesheet cannot reach a JS timer, so the typewriter has to\n * gate itself. The server snapshot is `true` so no animation runs before the\n * real preference is known.\n */\nconst usePrefersReducedMotion = () =>\n  useSyncExternalStore(\n    subscribeToReducedMotion,\n    () => window.matchMedia(REDUCED_MOTION_QUERY).matches,\n    () => true,\n  );\n\nexport interface SearchInputProps extends Omit<\n  React.ComponentProps<\"input\">,\n  \"onChange\" | \"placeholder\" | \"type\" | \"value\"\n> {\n  /** Accessible name for the clear button (default: \"Clear search\"). */\n  clearLabel?: string;\n  /** Milliseconds between removed characters while cycling (default: 100). */\n  deletingSpeed?: number;\n  /** Called after the value is cleared by the clear button. */\n  onClear?: () => void;\n  /** Called with the next search string on every keystroke. */\n  onValueChange: (value: string) => void;\n  /** Milliseconds a completed phrase rests before deleting (default: 1000). */\n  pauseDuration?: number;\n  /** Milliseconds between typed characters while cycling (default: 150). */\n  typingSpeed?: number;\n  /**\n   * The stable placeholder. Also the accessible description exposed through\n   * `aria-placeholder` while the animated placeholder is cycling.\n   */\n  placeholder?: string;\n  /**\n   * Phrases to type and delete in the placeholder. Decorative: cycling stops\n   * on focus, on a non-empty value, and under reduced motion.\n   */\n  placeholders?: string[];\n  /** The current search string. */\n  value: string;\n}\n\nconst SearchInput = ({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  clearLabel = \"Clear search\",\n  deletingSpeed = 100,\n  id,\n  onBlur,\n  onClear,\n  onFocus,\n  onValueChange,\n  pauseDuration = 1000,\n  placeholder = \"Search\",\n  placeholders,\n  typingSpeed = 150,\n  value,\n  ...props\n}: SearchInputProps) => {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const [isFocused, setIsFocused] = useState(false);\n  const [typed, setTyped] = useState(\"\");\n\n  // Empty entries are dropped: a zero-length phrase never reaches its own\n  // length in the typing branch, so the timer would reschedule forever.\n  const phrasesKey = placeholders?.filter(Boolean).join(PHRASE_SEPARATOR) ?? \"\";\n  // A moving placeholder under a typing user is hostile, so cycling pauses\n  // while the field is focused or holds a value.\n  const isCycling = phrasesKey !== \"\" && !prefersReducedMotion && !isFocused && value === \"\";\n\n  useEffect(() => {\n    if (!isCycling) {\n      return;\n    }\n\n    const phrases = phrasesKey.split(PHRASE_SEPARATOR);\n    let timeout: ReturnType<typeof setTimeout>;\n    let phraseIndex = 0;\n    let charIndex = 0;\n    let isDeleting = false;\n\n    const tick = () => {\n      const phrase = phrases[phraseIndex];\n\n      if (isDeleting) {\n        charIndex -= 1;\n        setTyped(phrase.slice(0, charIndex));\n\n        if (charIndex === 0) {\n          isDeleting = false;\n          phraseIndex = (phraseIndex + 1) % phrases.length;\n        }\n\n        timeout = setTimeout(tick, deletingSpeed);\n        return;\n      }\n\n      charIndex += 1;\n      setTyped(phrase.slice(0, charIndex));\n\n      if (charIndex === phrase.length) {\n        isDeleting = true;\n        timeout = setTimeout(tick, pauseDuration);\n        return;\n      }\n\n      timeout = setTimeout(tick, typingSpeed);\n    };\n\n    timeout = setTimeout(tick, typingSpeed);\n\n    return () => {\n      clearTimeout(timeout);\n      // Rewind so the next run starts from an empty placeholder rather than\n      // flashing the phrase the last run had reached.\n      setTyped(\"\");\n    };\n  }, [deletingSpeed, isCycling, pauseDuration, phrasesKey, typingSpeed]);\n\n  const handleClear = () => {\n    onValueChange(\"\");\n    onClear?.();\n    inputRef.current?.focus();\n  };\n\n  // Only name the input ourselves when nothing else does. An `id` implies an\n  // external <label htmlFor>, and overriding a visible label breaks\n  // label-in-name for voice control.\n  const hasExternalName = Boolean(ariaLabel ?? ariaLabelledBy ?? id);\n\n  return (\n    <InputGroup className={className} data-slot=\"search-input\">\n      <InputGroupAddon align=\"inline-start\">\n        <SearchIcon />\n      </InputGroupAddon>\n\n      <InputGroupInput\n        aria-label={hasExternalName ? ariaLabel : placeholder}\n        aria-labelledby={ariaLabelledBy}\n        aria-placeholder={placeholder}\n        autoComplete=\"off\"\n        className=\"[&::-webkit-search-cancel-button]:appearance-none\"\n        id={id}\n        onBlur={(event) => {\n          setIsFocused(false);\n          onBlur?.(event);\n        }}\n        onChange={(event) => onValueChange(event.target.value)}\n        onFocus={(event) => {\n          setIsFocused(true);\n          onFocus?.(event);\n        }}\n        placeholder={isCycling ? typed : placeholder}\n        type=\"search\"\n        value={value}\n        {...props}\n        ref={inputRef}\n      />\n\n      {value !== \"\" && (\n        <InputGroupAddon align=\"inline-end\">\n          <InputGroupButton\n            aria-label={clearLabel}\n            data-slot=\"search-input-clear\"\n            onClick={handleClear}\n            size=\"icon-xs\"\n          >\n            <XIcon />\n          </InputGroupButton>\n        </InputGroupAddon>\n      )}\n    </InputGroup>\n  );\n};\n\nexport { SearchInput };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
