{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-search",
  "type": "registry:ui",
  "title": "Expanding search",
  "description": "Expands from a toolbar action into a focused search field.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "forms-input"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/expanding-search/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Toolbar action expands continuously into an input field",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Expanded toolbar search field with focus",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/expanding-search.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/expanding-search.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst DISCLOSE = { type: \"spring\", stiffness: 380, damping: 38, mass: 0.7 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst COLLAPSED = 40;\nconst TEXT_LEFT = 34;\nconst CLEAR_SLOT = 35;\nconst COUNT_SLOT = 38;\nconst ANNOUNCE_DELAY = 500;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type UseExpandingSearchOptions = {\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  onSearch?: (value: string) => void;\n  onSubmit?: (value: string) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  debounce?: number;\n  collapseOnBlur?: boolean;\n  disabled?: boolean;\n};\n\nexport type UseExpandingSearchReturn = {\n  open: boolean;\n  focused: boolean;\n  query: string;\n  expand: () => void;\n  collapse: (returnFocus?: boolean) => void;\n  toggle: () => void;\n  clear: () => void;\n  inputRef: React.RefObject<HTMLInputElement>;\n  triggerRef: React.RefObject<HTMLButtonElement>;\n  rootProps: {\n    onFocus: (event: React.FocusEvent<HTMLElement>) => void;\n    onBlur: (event: React.FocusEvent<HTMLElement>) => void;\n  };\n  triggerProps: {\n    ref: React.RefObject<HTMLButtonElement>;\n    type: \"button\";\n    disabled: boolean;\n    tabIndex: number;\n    \"aria-expanded\": boolean;\n    onClick: () => void;\n  };\n  inputProps: {\n    ref: React.RefObject<HTMLInputElement>;\n    value: string;\n    disabled: boolean;\n    tabIndex: number;\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n    onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;\n    onFocus: () => void;\n  };\n};\n\nexport function useExpandingSearch({\n  value,\n  defaultValue = \"\",\n  onChange,\n  onSearch,\n  onSubmit,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  debounce = 220,\n  collapseOnBlur = true,\n  disabled = false,\n}: UseExpandingSearchOptions = {}): UseExpandingSearchReturn {\n  const [ownValue, setOwnValue] = useState(defaultValue);\n  const [ownOpen, setOwnOpen] = useState(defaultOpen);\n  const [focused, setFocused] = useState(false);\n\n  const query = value ?? ownValue;\n  const isOpen = open ?? ownOpen;\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const openRef = useRef(isOpen);\n\n  const latest = useRef({ query, onChange, onSearch, onSubmit, onOpenChange });\n  latest.current = { query, onChange, onSearch, onSubmit, onOpenChange };\n\n  useEffect(() => {\n    openRef.current = isOpen;\n  }, [isOpen]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const setOpen = useCallback((next: boolean) => {\n    if (openRef.current === next) return;\n    openRef.current = next;\n    setOwnOpen(next);\n    latest.current.onOpenChange?.(next);\n  }, []);\n\n  const commit = useCallback(\n    (next: string) => {\n      setOwnValue(next);\n      latest.current.onChange?.(next);\n      if (timer.current) clearTimeout(timer.current);\n      timer.current = setTimeout(() => {\n        timer.current = null;\n        latest.current.onSearch?.(next);\n      }, debounce);\n    },\n    [debounce],\n  );\n\n  const flush = useCallback(() => {\n    if (!timer.current) return;\n    clearTimeout(timer.current);\n    timer.current = null;\n    latest.current.onSearch?.(latest.current.query);\n  }, []);\n\n  const expand = useCallback(() => {\n    if (disabled) return;\n    setOpen(true);\n    inputRef.current?.focus();\n  }, [disabled, setOpen]);\n\n  const collapse = useCallback(\n    (returnFocus = false) => {\n      setOpen(false);\n      if (returnFocus) triggerRef.current?.focus();\n    },\n    [setOpen],\n  );\n\n  const toggle = useCallback(() => {\n    if (openRef.current) collapse(true);\n    else expand();\n  }, [collapse, expand]);\n\n  const clear = useCallback(() => {\n    commit(\"\");\n    inputRef.current?.focus();\n  }, [commit]);\n\n  const onRootFocus = useCallback(() => setFocused(true), []);\n\n  const onRootBlur = useCallback(\n    (event: React.FocusEvent<HTMLElement>) => {\n      const next = event.relatedTarget as Node | null;\n      if (next && event.currentTarget.contains(next)) return;\n      setFocused(false);\n      if (!collapseOnBlur) return;\n      if (!document.hasFocus()) return;\n      if (latest.current.query.length > 0) return;\n      setOpen(false);\n    },\n    [collapseOnBlur, setOpen],\n  );\n\n  const onInputKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLInputElement>) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        if (latest.current.query.length > 0) {\n          commit(\"\");\n          return;\n        }\n        collapse(true);\n        return;\n      }\n      if (event.key === \"Enter\") {\n        event.preventDefault();\n        flush();\n        latest.current.onSubmit?.(latest.current.query);\n      }\n    },\n    [collapse, commit, flush],\n  );\n\n  const onInputFocus = useCallback(() => setOpen(true), [setOpen]);\n\n  const onInputChange = useCallback(\n    (event: React.ChangeEvent<HTMLInputElement>) => commit(event.currentTarget.value),\n    [commit],\n  );\n\n  return {\n    open: isOpen,\n    focused,\n    query,\n    expand,\n    collapse,\n    toggle,\n    clear,\n    inputRef,\n    triggerRef,\n    rootProps: { onFocus: onRootFocus, onBlur: onRootBlur },\n    triggerProps: {\n      ref: triggerRef,\n      type: \"button\",\n      disabled,\n      tabIndex: isOpen ? -1 : 0,\n      \"aria-expanded\": isOpen,\n      onClick: expand,\n    },\n    inputProps: {\n      ref: inputRef,\n      value: query,\n      disabled,\n      tabIndex: isOpen ? 0 : -1,\n      onChange: onInputChange,\n      onKeyDown: onInputKeyDown,\n      onFocus: onInputFocus,\n    },\n  };\n}\n\nexport type ExpandingSearchProps = UseExpandingSearchOptions & {\n  label?: string;\n  placeholder?: string;\n  resultCount?: number;\n  align?: \"left\" | \"right\";\n  className?: string;\n  clearLabel?: string;\n  formatResults?: (count: number, query: string) => string;\n};\n\nexport function ExpandingSearch({\n  label = \"Search\",\n  placeholder = \"Search\",\n  resultCount,\n  align = \"right\",\n  className = \"\",\n  clearLabel = \"Clear search\",\n  formatResults = (count, query) => `${count} ${count === 1 ? \"result\" : \"results\"} for ${query}`,\n  ...options\n}: ExpandingSearchProps) {\n  const reduced = useReducedMotion();\n  const auto = useId();\n  const inputId = `${auto}-field`;\n\n  const {\n    open,\n    focused,\n    query,\n    clear,\n    inputRef,\n    rootProps,\n    triggerProps,\n    inputProps,\n  } = useExpandingSearch(options);\n\n  const trackRef = useRef<HTMLDivElement | null>(null);\n  const [track, setTrack] = useState(0);\n\n  useIsomorphicLayoutEffect(() => {\n    const el = trackRef.current;\n    if (!el) return;\n    const read = (w: number) =>\n      setTrack((prev) => (Math.abs(prev - w) < 0.5 ? prev : w));\n    read(el.getBoundingClientRect().width);\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0];\n      if (box) read(box.contentRect.width);\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const [announced, setAnnounced] = useState(\"\");\n  useEffect(() => {\n    const id = setTimeout(() => {\n      if (!open || query.length === 0 || resultCount === undefined) {\n        setAnnounced(\"\");\n        return;\n      }\n      setAnnounced(formatResults(resultCount, query));\n    }, ANNOUNCE_DELAY);\n    return () => clearTimeout(id);\n  }, [formatResults, open, query, resultCount]);\n\n  const expanded = Math.max(COLLAPSED, track);\n  const rightInset = CLEAR_SLOT + (resultCount === undefined ? 0 : COUNT_SLOT);\n  const inner = Math.max(0, expanded - TEXT_LEFT - rightInset);\n  const filled = query.length > 0;\n  const shellMotion = reduced ? INSTANT : DISCLOSE;\n  const fadeMotion = reduced ? INSTANT : CROSSFADE;\n  const cellMotion = reduced ? INSTANT : CELL;\n\n  return (\n    <div\n      ref={trackRef}\n      role=\"search\"\n      className={`relative h-10 w-full ${className}`}\n      {...rootProps}\n    >\n      <motion.div\n        initial={false}\n        animate={{\n          clipPath: open\n            ? \"inset(0 0 0 0 round 10px)\"\n            : align === \"right\"\n              ? `inset(0 0 0 calc(100% - ${COLLAPSED}px) round 10px)`\n              : `inset(0 calc(100% - ${COLLAPSED}px) 0 0 round 10px)`,\n        }}\n        transition={shellMotion}\n        style={{\n          width: expanded,\n        }}\n        onMouseDown={(event) => {\n          if (event.target !== event.currentTarget) return;\n          event.preventDefault();\n          if (open) inputRef.current?.focus();\n        }}\n        className={`absolute inset-y-0 ${\n          align === \"right\" ? \"right-0\" : \"left-0\"\n        } overflow-hidden rounded-[10px] border-2 transition-[background-color,border-color,box-shadow] duration-150 ${\n          focused\n            ? \"border-[#4568FF] bg-white dark:border-[#93B0FF] dark:bg-[#252522]\"\n            : \"border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n        }`}\n      >\n        <motion.input\n          {...inputProps}\n          id={inputId}\n          type=\"search\"\n          placeholder={placeholder}\n          aria-label={label}\n          aria-describedby={`${auto}-live`}\n          autoComplete=\"off\"\n          spellCheck={false}\n          enterKeyHint=\"search\"\n          style={{ width: inner, left: TEXT_LEFT }}\n          initial={false}\n          animate={{ opacity: open ? 1 : 0 }}\n          transition={\n            reduced ? INSTANT : { ...CROSSFADE, delay: open ? 0.06 : 0 }\n          }\n          className=\"absolute inset-y-0 bg-transparent text-[13px] leading-9 text-stone-700 outline-none focus-visible:outline-none placeholder:text-stone-400 dark:text-stone-200 dark:placeholder:text-stone-500 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none\"\n        />\n\n        <motion.div\n          initial={false}\n          animate={{ opacity: open ? 1 : 0 }}\n          transition={fadeMotion}\n          className=\"pointer-events-none absolute inset-y-0 right-[7px] flex items-center gap-1.5\"\n        >\n          {resultCount === undefined ? null : (\n            <span\n              aria-hidden\n              className=\"w-8 truncate text-right font-mono text-[9.5px] tabular-nums text-stone-500 dark:text-stone-400\"\n            >\n              {filled ? resultCount : \"\"}\n            </span>\n          )}\n\n          <motion.button\n            type=\"button\"\n            onClick={clear}\n            tabIndex={open && filled ? 0 : -1}\n            aria-label={clearLabel}\n            aria-controls={inputId}\n            initial={false}\n            animate={{ opacity: filled ? 1 : 0 }}\n            transition={cellMotion}\n            className={`grid size-[22px] place-items-center rounded-[6px] text-stone-500 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4568FF] dark:text-stone-400 dark:focus-visible:outline-[#93B0FF] ${\n              open && filled ? \"pointer-events-auto\" : \"\"\n            }`}\n          >\n            <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\" aria-hidden>\n              <path\n                d=\"M1.7 1.7 L9.3 9.3 M9.3 1.7 L1.7 9.3\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          </motion.button>\n        </motion.div>\n\n      </motion.div>\n\n      <motion.button\n        {...triggerProps}\n        aria-label={label}\n        aria-controls={inputId}\n        initial={false}\n        animate={{\n          transform: `translate3d(${align === \"right\" && open ? -(expanded - COLLAPSED) : 0}px, 0, 0)`,\n        }}\n        transition={shellMotion}\n        className={`absolute inset-y-0 z-10 grid w-10 place-items-center rounded-[8px] text-stone-500 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4568FF] disabled:opacity-50 dark:text-stone-400 dark:focus-visible:outline-[#93B0FF] ${\n          align === \"right\" ? \"right-0\" : \"left-0\"\n        } ${open ? \"pointer-events-none\" : \"\"}`}\n      >\n        <svg width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\" aria-hidden>\n          <circle cx=\"6.4\" cy=\"6.4\" r=\"4.5\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n          <path\n            d=\"M9.8 9.8 L13.2 13.2\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.4\"\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      </motion.button>\n\n      <span id={`${auto}-live`} aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}
