{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-search",
  "type": "registry:ui",
  "title": "Expanding Search",
  "description": "A search icon that springs open into a full field with recent searches, suggestions and a / shortcut.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/expanding-search.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface ExpandingSearchSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface ExpandingSearchProps\n  extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"onSubmit\" | \"children\" | \"onChange\"> {\n  /** Called with the trimmed query when the user submits or picks a suggestion. */\n  onSearch?: (query: string) => void;\n  /** Called on every keystroke. */\n  onQueryChange?: (query: string) => void;\n  /** Suggestions, filtered by case-insensitive substring as the user types. */\n  suggestions?: string[];\n  /** Initial recent searches (most recent first). New searches are added to the front. */\n  defaultRecent?: string[];\n  /** Show and record recent searches. */\n  showRecent?: boolean;\n  /** How many recent searches to keep. */\n  maxRecent?: number;\n  /** Called whenever the recent list changes, e.g. to persist it. */\n  onRecentChange?: (recent: string[]) => void;\n  /** Visually hidden label for the field; also names the collapsed button. */\n  label?: string;\n  placeholder?: string;\n  /** Single key that opens the search from anywhere on the page. Empty string disables it. */\n  shortcut?: string;\n  /** Width of the open field in px (capped by the parent's width). */\n  expandedWidth?: number;\n  /** Spring for the width morph. */\n  spring?: ExpandingSearchSpring;\n  /** Collapse back to the icon when focus leaves an empty field. */\n  collapseOnBlur?: boolean;\n  /** Start expanded (without stealing focus). */\n  defaultOpen?: boolean;\n  /** Focus ring, caret and highlight colour (any CSS colour). */\n  accent?: string;\n}\n\ntype Item = { text: string; kind: \"recent\" | \"suggestion\" };\n\nconst COLLAPSED = 40;\n\nconst isEditable = (el: EventTarget | null) => {\n  if (!(el instanceof HTMLElement)) return false;\n  const tag = el.tagName;\n  return tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || el.isContentEditable;\n};\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLowerCase().indexOf(query) : -1;\n  if (at === -1) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-semibold text-zinc-950 underline decoration-[var(--es-accent)] decoration-2 underline-offset-[3px] dark:text-white\">\n        {text.slice(at, at + query.length)}\n      </mark>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n\n/**\n * A search icon that springs open into a full search field. It is a `role=\"search\"` landmark with\n * a visually hidden label; the field is an ARIA 1.2 combobox whose listbox offers recent searches\n * and filtered suggestions via aria-activedescendant. A single-key shortcut (\"/\" by default)\n * opens it unless the user is typing elsewhere; Escape collapses it and returns focus.\n */\nexport function ExpandingSearch({\n  onSearch,\n  onQueryChange,\n  suggestions = [],\n  defaultRecent = [],\n  showRecent = true,\n  maxRecent = 5,\n  onRecentChange,\n  label = \"Search\",\n  placeholder = \"Search components…\",\n  shortcut = \"/\",\n  expandedWidth = 320,\n  spring = { stiffness: 420, damping: 34, mass: 0.8 },\n  collapseOnBlur = true,\n  defaultOpen = false,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: ExpandingSearchProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const inputId = `es-${uid}`;\n  const listboxId = `${inputId}-listbox`;\n  const optionId = (i: number) => `${inputId}-opt-${i}`;\n\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [query, setQuery] = React.useState(\"\");\n  const [panelOpen, setPanelOpen] = React.useState(false);\n  const [activeIndex, setActiveIndex] = React.useState(-1);\n  const [recent, setRecent] = React.useState<string[]>(defaultRecent);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const formRef = React.useRef<HTMLFormElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  /** Where focus should land after the next open/close render. */\n  const pendingFocus = React.useRef<\"input\" | \"trigger\" | null>(null);\n\n  /* ------------------------------- derived items ------------------------------ */\n\n  const q = query.trim().toLowerCase();\n  const recentItems: Item[] = showRecent\n    ? recent.filter((r) => !q || r.toLowerCase().includes(q)).map((text) => ({ text, kind: \"recent\" }))\n    : [];\n  const suggestionItems: Item[] = suggestions\n    .filter((s) => (!q || s.toLowerCase().includes(q)) && !recentItems.some((r) => r.text.toLowerCase() === s.toLowerCase()))\n    .slice(0, q ? 6 : 4)\n    .map((text) => ({ text, kind: \"suggestion\" }));\n  const items = [...recentItems, ...suggestionItems];\n  const listOpen = open && panelOpen && items.length > 0;\n  const active = listOpen && activeIndex < items.length ? activeIndex : -1;\n\n  /* --------------------------------- actions -------------------------------- */\n\n  const updateRecent = (next: string[]) => {\n    setRecent(next);\n    onRecentChange?.(next);\n  };\n\n  const expand = (focus: boolean) => {\n    if (focus) pendingFocus.current = \"input\";\n    setOpen(true);\n    setPanelOpen(true);\n    if (focus && open) inputRef.current?.focus();\n  };\n\n  const collapse = (returnFocus: boolean) => {\n    if (returnFocus) pendingFocus.current = \"trigger\";\n    setOpen(false);\n    setPanelOpen(false);\n    setActiveIndex(-1);\n    setQuery(\"\");\n    onQueryChange?.(\"\");\n  };\n\n  const run = (text: string) => {\n    const value = text.trim();\n    if (!value) return;\n    setQuery(value);\n    setPanelOpen(false);\n    setActiveIndex(-1);\n    if (showRecent) updateRecent([value, ...recent.filter((r) => r.toLowerCase() !== value.toLowerCase())].slice(0, maxRecent));\n    setAnnouncement(`Searching for ${value}.`);\n    onSearch?.(value);\n  };\n\n  const scrollToOption = (index: number) => {\n    const list = listRef.current;\n    const el = list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(index))}`);\n    if (!list || !el) return;\n    // Scroll the listbox only — scrollIntoView would also move the page.\n    if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 4;\n    else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)\n      list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;\n  };\n\n  const move = (delta: 1 | -1) => {\n    if (!items.length) return;\n    const next = active === -1 ? (delta === 1 ? 0 : items.length - 1) : (active + delta + items.length) % items.length;\n    setActiveIndex(next);\n    requestAnimationFrame(() => scrollToOption(next));\n  };\n\n  // Move focus once the open/closed markup exists.\n  React.useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    if (target === \"input\" && open) inputRef.current?.focus();\n    if (target === \"trigger\" && !open) triggerRef.current?.focus();\n  }, [open]);\n\n  // Global single-key shortcut, ignored while typing in any other field or with modifiers.\n  const openFromShortcut = React.useEffectEvent((event: KeyboardEvent) => {\n    if (!shortcut || event.key !== shortcut || event.metaKey || event.ctrlKey || event.altKey) return;\n    if (event.defaultPrevented || isEditable(event.target)) return;\n    event.preventDefault();\n    expand(true);\n  });\n  React.useEffect(() => {\n    if (!shortcut) return;\n    const onKey = (event: KeyboardEvent) => openFromShortcut(event);\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [shortcut]);\n\n  /* --------------------------------- keyboard -------------------------------- */\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        if (!panelOpen) setPanelOpen(true);\n        else move(1);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        if (!panelOpen) setPanelOpen(true);\n        else move(-1);\n        break;\n      case \"Enter\":\n        if (active !== -1) {\n          event.preventDefault();\n          run(items[active].text);\n        }\n        break;\n      case \"Delete\":\n        // Shift+Delete forgets the highlighted recent search.\n        if (event.shiftKey && active !== -1 && items[active].kind === \"recent\") {\n          event.preventDefault();\n          const text = items[active].text;\n          updateRecent(recent.filter((r) => r !== text));\n          setAnnouncement(`${text} removed from recent searches.`);\n          setActiveIndex(-1);\n        }\n        break;\n      case \"Escape\":\n        event.preventDefault();\n        collapse(true);\n        break;\n    }\n  };\n\n  /* ---------------------------------- render --------------------------------- */\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const statusText = listOpen\n    ? `${items.length} ${items.length === 1 ? \"suggestion\" : \"suggestions\"}. Use the arrow keys to choose.`\n    : \"\";\n\n  const renderOption = (item: Item, index: number) => (\n    <div\n      key={`${item.kind}-${item.text}`}\n      id={optionId(index)}\n      role=\"option\"\n      aria-selected={index === active}\n      onClick={() => run(item.text)}\n      onPointerMove={(e) => {\n        if (e.pointerType === \"mouse\" && index !== active) setActiveIndex(index);\n      }}\n      className={`group/opt flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] transition-colors duration-100 ${\n        index === active\n          ? \"bg-[color-mix(in_srgb,var(--es-accent)_12%,transparent)] text-zinc-950 dark:text-white\"\n          : \"text-zinc-700 dark:text-zinc-300\"\n      }`}\n    >\n      <span aria-hidden=\"true\" className={`grid size-4 shrink-0 place-items-center ${index === active ? \"text-[var(--es-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"}`}>\n        {item.kind === \"recent\" ? (\n          <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\">\n            <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n            <path d=\"M8 5v3.2l2 1.3\" />\n          </svg>\n        ) : (\n          <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M3 13L13 3M6 3h7v7\" />\n          </svg>\n        )}\n      </span>\n      <span className=\"min-w-0 flex-1 truncate\">\n        <Highlight text={item.text} query={q} />\n      </span>\n      {index === active && (\n        <span aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n          Enter\n        </span>\n      )}\n    </div>\n  );\n\n  return (\n    <form\n      ref={formRef}\n      role=\"search\"\n      {...props}\n      onSubmit={(event) => {\n        event.preventDefault();\n        run(query);\n      }}\n      onBlur={(event) => {\n        // Focus left the whole search (not just moved between its parts).\n        if (formRef.current?.contains(event.relatedTarget as Node | null)) return;\n        setPanelOpen(false);\n        setActiveIndex(-1);\n        if (collapseOnBlur && !query.trim()) collapse(false);\n      }}\n      className={`relative max-w-full ${className}`}\n      style={{ [\"--es-accent\" as string]: accent, ...style }}\n    >\n      <motion.div\n        initial={false}\n        animate={{ width: open ? expandedWidth : COLLAPSED }}\n        transition={transition}\n        className={`group/es relative flex h-10 max-w-full items-center overflow-hidden rounded-full border bg-white transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none dark:bg-zinc-950 ${\n          open\n            ? \"border-zinc-900/15 focus-within:border-[var(--es-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--es-accent)_22%,transparent)] dark:border-white/15\"\n            : \"border-zinc-900/15 hover:border-zinc-900/35 dark:border-white/15 dark:hover:border-white/35\"\n        }`}\n      >\n        {open ? (\n          <>\n            <span aria-hidden=\"true\" className=\"grid size-10 shrink-0 place-items-center text-zinc-500 dark:text-zinc-400\">\n              <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\">\n                <circle cx=\"7\" cy=\"7\" r=\"4.75\" />\n                <path d=\"M10.5 10.5L14 14\" />\n              </svg>\n            </span>\n            <label htmlFor={inputId} className=\"sr-only\">\n              {label}\n            </label>\n            <input\n              ref={inputRef}\n              id={inputId}\n              type=\"search\"\n              role=\"combobox\"\n              aria-expanded={listOpen}\n              aria-controls={listboxId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={active !== -1 ? optionId(active) : undefined}\n              aria-keyshortcuts={shortcut || undefined}\n              enterKeyHint=\"search\"\n              autoComplete=\"off\"\n              spellCheck={false}\n              value={query}\n              placeholder={placeholder}\n              onChange={(event) => {\n                setQuery(event.target.value);\n                onQueryChange?.(event.target.value);\n                setPanelOpen(true);\n                setActiveIndex(-1);\n                setAnnouncement(\"\");\n              }}\n              onFocus={() => setPanelOpen(true)}\n              onClick={() => setPanelOpen(true)}\n              onKeyDown={handleKeyDown}\n              className=\"h-full min-w-0 flex-1 bg-transparent pr-2 text-[14px] text-zinc-950 caret-[var(--es-accent)] outline-hidden placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500 [&::-webkit-search-cancel-button]:appearance-none\"\n            />\n            {query ? (\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => {\n                  setQuery(\"\");\n                  onQueryChange?.(\"\");\n                  setPanelOpen(true);\n                  setActiveIndex(-1);\n                  inputRef.current?.focus();\n                }}\n                className=\"mr-1.5 grid size-7 shrink-0 place-items-center rounded-full text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--es-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n              >\n                <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                  <path d=\"M3 3l6 6M9 3l-6 6\" />\n                </svg>\n              </button>\n            ) : (\n              <kbd\n                aria-hidden=\"true\"\n                className=\"mr-2 shrink-0 rounded-md border border-zinc-900/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-400 opacity-0 transition-opacity group-focus-within/es:opacity-100 dark:border-white/10 dark:text-zinc-500\"\n              >\n                Esc\n              </kbd>\n            )}\n          </>\n        ) : (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-label={label}\n            aria-keyshortcuts={shortcut || undefined}\n            title={shortcut ? `${label} (${shortcut})` : label}\n            onClick={() => expand(true)}\n            className=\"grid size-10 shrink-0 place-items-center rounded-full text-zinc-700 outline-hidden transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--es-accent)] dark:text-zinc-300 dark:hover:text-white\"\n          >\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\">\n              <circle cx=\"7\" cy=\"7\" r=\"4.75\" />\n              <path d=\"M10.5 10.5L14 14\" />\n            </svg>\n          </button>\n        )}\n      </motion.div>\n\n      <AnimatePresence>\n        {listOpen && (\n          <motion.div\n            key=\"panel\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: { duration: 0.12 } }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 560, damping: 38, mass: 0.6 }}\n            style={{ transformOrigin: \"top\", width: expandedWidth }}\n            className=\"absolute left-0 top-[calc(100%+6px)] z-30 max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900\"\n            onMouseDown={(e) => e.preventDefault()}\n          >\n            <div ref={listRef} id={listboxId} role=\"listbox\" aria-label={`${label} suggestions`} className=\"max-h-72 overflow-y-auto overscroll-contain p-1\">\n              {recentItems.length > 0 && (\n                <div role=\"group\" aria-labelledby={`${inputId}-recent`}>\n                  <div\n                    id={`${inputId}-recent`}\n                    role=\"presentation\"\n                    className=\"px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\"\n                  >\n                    Recent\n                  </div>\n                  {recentItems.map((item, i) => renderOption(item, i))}\n                </div>\n              )}\n              {suggestionItems.length > 0 && (\n                <div\n                  role=\"group\"\n                  aria-labelledby={`${inputId}-suggest`}\n                  className={recentItems.length ? \"mt-1 border-t border-zinc-900/[0.06] pt-1 dark:border-white/[0.06]\" : \"\"}\n                >\n                  <div id={`${inputId}-suggest`} role=\"presentation\" className=\"px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n                    {q ? \"Suggestions\" : \"Popular\"}\n                  </div>\n                  {suggestionItems.map((item, i) => renderOption(item, recentItems.length + i))}\n                </div>\n              )}\n            </div>\n            {showRecent && recent.length > 0 && (\n              <div className=\"flex items-center justify-between gap-3 border-t border-zinc-900/[0.06] px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/[0.06] dark:text-zinc-500\">\n                <span aria-hidden=\"true\">Shift + Del forgets</span>\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  onClick={() => {\n                    updateRecent([]);\n                    setActiveIndex(-1);\n                    setAnnouncement(\"Recent searches cleared.\");\n                  }}\n                  className=\"uppercase tracking-[0.14em] transition-colors hover:text-zinc-900 dark:hover:text-white\"\n                >\n                  Clear recent\n                </button>\n              </div>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement || statusText}\n      </span>\n    </form>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/expanding-search"
}
