{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-search",
  "type": "registry:ui",
  "title": "Expanding search",
  "description": "An icon that morphs into a search field with results beneath it.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/expanding-search/expanding-search.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from \"motion/react\";\nimport type { MotionValue } from \"motion/react\";\nimport { Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ExpandingSearchItem {\n  id: string;\n  title: string;\n  /** One short line under the title, such as the type, owner, or last edit. */\n  meta?: string;\n  /** Results gather under this heading, in the order groups first appear in `items`. */\n  group?: string;\n  icon?: ReactNode;\n  /** Extra words that should also find this item. */\n  keywords?: string[];\n}\n\n/**\n * A search that waits as an icon button until it is needed. Use it in headers and toolbars where a full field would crowd the layout.\n * The button morphs into the field, results unfold beneath it, and Escape, choosing a result, or leaving the field empty folds it back into the icon.\n * Place it in the space the field may grow into; it fills that space's width and keeps the button on the `anchor` edge.\n */\nexport interface ExpandingSearchProps {\n  /** Names the button, the field, and the results, such as \"Search projects and docs\". */\n  label: string;\n  items: ExpandingSearchItem[];\n  /** Shown before anything is typed, such as recent searches. */\n  suggestions?: ExpandingSearchItem[];\n  suggestionsLabel?: string;\n  placeholder?: string;\n  onSelect?: (item: ExpandingSearchItem) => void;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** The widest the field grows. It never grows past its container. */\n  expandedWidth?: number;\n  maxResults?: number;\n  /** The edge the button sits on; the field grows away from it. */\n  anchor?: \"start\" | \"end\";\n  /** A short tip under the empty state. */\n  emptyHint?: string;\n  className?: string;\n}\n\ntype Group = { name: string; items: ExpandingSearchItem[] };\ntype Mode = \"suggestions\" | \"results\" | \"empty\";\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst FALLBACK_SIZE = 44;\n\n/* One shell is both the button and the field. Its width rides a spring and its radius follows the width, from a circle to the\n   control radius. The root is the lane the field may grow into; only the shell and the panel take pointer events. A compact\n   press while it is still a button; the morph takes over on release. */\nconst shellClass =\n  \"pointer-events-auto absolute top-0 right-0 h-full overflow-hidden rounded-[calc(var(--control-height-md)_/_2_-_(var(--control-height-md)_/_2_-_var(--radius-control))_*_var(--es-open,0))] border border-line bg-surface text-ink-2 [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-standard)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] group-data-[anchor=start]/es:right-auto group-data-[anchor=start]/es:left-0 data-expanded:border-line-strong data-expanded:text-ink has-[>button:first-child:focus-visible]:border-ink has-[>button:first-child:focus-visible]:text-ink has-[>button:first-child:focus-visible]:shadow-[0_0_0_3px_var(--focus-ring)] has-[>input:focus]:border-ink has-[>input:focus]:text-ink has-[>input:focus]:shadow-[0_0_0_3px_var(--focus-ring)] not-data-expanded:hover:bg-hover not-data-expanded:hover:text-ink data-expanded:not-has-[>input:focus]:hover:border-ink not-data-expanded:has-[>button:first-child:active]:[transform:scale(.97)] not-data-expanded:has-[>button:first-child:active]:[transition-duration:var(--duration-fast),var(--duration-fast),var(--duration-fast),var(--duration-standard),var(--duration-instant)] motion-reduce:transition-none motion-reduce:not-data-expanded:has-[>button:first-child:active]:[transform:none]\";\n/* Nested corners step down from the panel by its inset. The highlight paints under every row's content, so text never hides while it travels. */\nconst optionClass =\n  \"group/option relative flex min-h-12 cursor-pointer items-center gap-3 rounded-[calc(var(--radius-panel)-6px)] px-3 py-1.5 text-ink select-none [-webkit-tap-highlight-color:transparent]\";\n\n/** Title starts beat word starts, which beat matches inside a word, which beat keyword and meta matches. Ties keep the original order. */\nfunction rank(items: ExpandingSearchItem[], query: string, limit: number) {\n  const q = query.toLocaleLowerCase();\n  const scored: { item: ExpandingSearchItem; score: number; order: number }[] = [];\n  items.forEach((item, order) => {\n    const title = item.title.toLocaleLowerCase();\n    const at = title.indexOf(q);\n    const extra = [item.meta ?? \"\", ...(item.keywords ?? [])].some((word) => word.toLocaleLowerCase().includes(q));\n    const score = at === 0 ? 0 : at > 0 && /[\\s\\-/]/.test(title[at - 1]!) ? 1 : at > 0 ? 2 : extra ? 3 : -1;\n    if (score >= 0) scored.push({ item, score, order });\n  });\n  return scored\n    .sort((a, b) => a.score - b.score || a.order - b.order)\n    .slice(0, limit)\n    .map((entry) => entry.item);\n}\n\nfunction groupBy(list: ExpandingSearchItem[], source: ExpandingSearchItem[]): Group[] {\n  const order = new Map<string, number>();\n  source.forEach((item, index) => {\n    const name = item.group ?? \"\";\n    if (!order.has(name)) order.set(name, index);\n  });\n  const groups = new Map<string, ExpandingSearchItem[]>();\n  list.forEach((item) => {\n    const name = item.group ?? \"\";\n    groups.set(name, [...(groups.get(name) ?? []), item]);\n  });\n  return [...groups]\n    .sort((a, b) => (order.get(a[0]) ?? 0) - (order.get(b[0]) ?? 0))\n    .map(([name, items]) => ({ name, items }));\n}\n\nfunction Match({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) : -1;\n  if (at < 0) return text;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-medium text-ink\">{text.slice(at, at + query.length)}</mark>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n\n/** The panel shares the field's width value, so on first open it unfolds out of the field; afterwards only its height follows the content. */\nfunction Panel({ width, reduced, children }: { width: MotionValue<number>; reduced: boolean; children: ReactNode }) {\n  const panelRef = useRef<HTMLDivElement>(null);\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const height = useMotionValue(0);\n  useLayoutEffect(() => {\n    const panel = panelRef.current,\n      body = bodyRef.current;\n    if (!panel || !body || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = body.offsetHeight + panel.offsetHeight - panel.clientHeight;\n      if (reduced) height.jump(next);\n      else animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  // Reduced motion skips the unfold: the panel is its content's height from the first frame and only fades.\n  return (\n    <motion.div\n      ref={panelRef}\n      className=\"pointer-events-auto absolute top-[calc(100%+8px)] right-0 z-40 overflow-hidden rounded-panel border border-line bg-surface shadow-floating group-data-[anchor=start]/es:right-auto group-data-[anchor=start]/es:left-0\"\n      style={{ width, height: reduced ? \"auto\" : height }}\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1, transition: { duration: reduced ? 0.15 : motionTokens.duration.fast, ease: enter } }}\n      exit={{ opacity: 0, transition: { duration: reduced ? 0.1 : motionTokens.duration.instant, ease: standard } }}\n    >\n      <div\n        ref={bodyRef}\n        className=\"relative max-h-[min(22rem,60vh)] w-[calc(var(--es-width,360px)-2px)] overflow-x-hidden overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]\"\n      >\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\n/** Offsets ignore transforms, so they describe where rows sit in the list, not where a gliding row happens to be mid-flight. */\nfunction offsetWithin(node: HTMLElement, container: HTMLElement) {\n  let top = 0;\n  let current: HTMLElement | null = node;\n  while (current && current !== container) {\n    top += current.offsetTop;\n    current = current.offsetParent as HTMLElement | null;\n  }\n  return top;\n}\n\ninterface ListboxProps {\n  id: string;\n  label: string;\n  groups: Group[];\n  query: string;\n  activeIndex: number;\n  optionId: (item: ExpandingSearchItem) => string;\n  reduced: boolean;\n  keyboard: RefObject<boolean>;\n  onHover: (index: number) => void;\n  onChoose: (item: ExpandingSearchItem) => void;\n}\n\nfunction Listbox({\n  id,\n  label,\n  groups,\n  query,\n  activeIndex,\n  optionId,\n  reduced,\n  keyboard,\n  onHover,\n  onChoose,\n}: ListboxProps) {\n  const listRef = useRef<HTMLDivElement>(null);\n  const flat = groups.flatMap((group) => group.items);\n  const layoutKey = flat.map((item) => item.id).join(\"|\");\n  const activeItem = flat[activeIndex];\n  const activeId = activeItem ? optionId(activeItem) : \"\";\n  /** The highlight lives inside the active row, so it rides that row wherever it glides. Moving to another row, it starts where the old one was and springs home. */\n  const travel = useMotionValue(0);\n  const fade = useMotionValue(1);\n  const last = useRef<{ id: string; set: string; top: number } | null>(null);\n\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    const row = activeId ? document.getElementById(activeId) : null;\n    if (!list || !row) {\n      last.current = null;\n      return;\n    }\n    const top = offsetWithin(row, list);\n    const previous = last.current;\n    last.current = { id: activeId, set: layoutKey, top };\n    // Same row: its highlight is already in place, and any travel still in flight stays relative to it.\n    if (previous?.id === activeId) return;\n    if (previous && previous.set === layoutKey && !reduced) {\n      const velocity = travel.getVelocity();\n      travel.jump(previous.top + travel.get() - top);\n      animate(travel, 0, { ...motionTokens.spring.snappy, velocity });\n    } else {\n      // Nothing was highlighted, or new results arrived: it fades in on its row, without travel.\n      travel.jump(0);\n      if (reduced) fade.jump(1);\n      else {\n        fade.jump(0);\n        animate(fade, 1, { duration: motionTokens.duration.instant, ease: enter });\n      }\n    }\n    if (keyboard.current) row.scrollIntoView({ block: \"nearest\" });\n  }, [activeId, layoutKey, reduced, travel, fade, keyboard]);\n\n  /* A flex gap, not margins: a group popping out while it leaves never lets the next one's margin collapse through the list. */\n  return (\n    <div ref={listRef} id={id} role=\"listbox\" aria-label={label} className=\"relative isolate flex flex-col gap-1\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {groups.map((group) => {\n          const headingId = `${id}-${group.name || \"results\"}`.replace(/\\s+/g, \"-\");\n          return (\n            <motion.div\n              key={group.name}\n              role=\"group\"\n              aria-labelledby={group.name ? headingId : undefined}\n              className=\"relative\"\n              layout={reduced ? false : \"position\"}\n              layoutDependency={layoutKey}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              transition={{\n                opacity: { duration: motionTokens.duration.fast, ease: enter },\n                layout: motionTokens.spring.smooth,\n              }}\n            >\n              {group.name && (\n                <div\n                  id={headingId}\n                  className=\"px-3 pt-2 pb-1 text-xs leading-body font-medium text-ink-3\"\n                  role=\"presentation\"\n                >\n                  {group.name}\n                </div>\n              )}\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {group.items.map((item) => {\n                  const position = flat.indexOf(item);\n                  const active = position === activeIndex;\n                  return (\n                    <motion.div\n                      key={item.id}\n                      id={optionId(item)}\n                      role=\"option\"\n                      aria-selected={active}\n                      data-active={active || undefined}\n                      className={optionClass}\n                      layout={reduced ? false : \"position\"}\n                      layoutDependency={layoutKey}\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                      transition={{\n                        opacity: { duration: motionTokens.duration.fast, ease: enter },\n                        layout: motionTokens.spring.smooth,\n                      }}\n                      onPointerMove={() => {\n                        if (!active) {\n                          keyboard.current = false;\n                          onHover(position);\n                        }\n                      }}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onClick={() => onChoose(item)}\n                    >\n                      {active && (\n                        <motion.span\n                          className=\"pointer-events-none absolute inset-0 -z-1 rounded-[inherit] bg-[color-mix(in_oklab,var(--ink)_6%,transparent)]\"\n                          style={{ y: travel, opacity: fade }}\n                          aria-hidden=\"true\"\n                        />\n                      )}\n                      {item.icon && (\n                        <span\n                          className=\"grid w-[18px] flex-none place-items-center text-ink-3 transition-[color] duration-fast ease-standard group-data-active/option:text-ink motion-reduce:transition-none\"\n                          aria-hidden=\"true\"\n                        >\n                          {item.icon}\n                        </span>\n                      )}\n                      <span className=\"grid min-w-0\">\n                        <span className=\"truncate text-sm leading-body text-ink\">\n                          <Match text={item.title} query={query} />\n                        </span>\n                        {item.meta && <span className=\"truncate text-xs leading-body text-ink-3\">{item.meta}</span>}\n                      </span>\n                    </motion.div>\n                  );\n                })}\n              </AnimatePresence>\n            </motion.div>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport function ExpandingSearch({\n  label,\n  items,\n  suggestions = [],\n  suggestionsLabel = \"Recent\",\n  placeholder,\n  onSelect,\n  onExpandedChange,\n  expandedWidth = 360,\n  maxResults = 6,\n  anchor = \"end\",\n  emptyHint = \"Try a shorter word or check the spelling.\",\n  className,\n}: ExpandingSearchProps) {\n  const id = useId();\n  const reduced = useReducedMotion() ?? false;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const shellRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const [expanded, setExpanded] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(-1);\n  const open = useRef(false);\n  const size = useRef({ collapsed: FALLBACK_SIZE, expanded: expandedWidth });\n  const width = useMotionValue(FALLBACK_SIZE);\n  /** True while the last highlight move came from the keyboard, so only keyboard moves scroll the list. */\n  const keyboard = useRef(false);\n\n  // The corner radius follows the width: a circle while collapsed, the control radius once open. It rides the same spring, so the two never drift apart.\n  const syncShape = useCallback((value: number) => {\n    const { collapsed, expanded: full } = size.current;\n    shellRef.current?.style.setProperty(\n      \"--es-open\",\n      String(Math.min(1, Math.max(0, (value - collapsed) / Math.max(1, full - collapsed)))),\n    );\n  }, []);\n  useMotionValueEvent(width, \"change\", syncShape);\n\n  // The field grows to its own limit or its container, whichever is smaller. Container resizes are followed at once; they are not the person's action.\n  useLayoutEffect(() => {\n    const root = rootRef.current,\n      shell = shellRef.current;\n    if (!root || !shell) return;\n    const measure = () => {\n      const collapsed = shell.offsetHeight || FALLBACK_SIZE;\n      const full = Math.max(collapsed, Math.min(expandedWidth, root.clientWidth));\n      size.current = { collapsed, expanded: full };\n      root.style.setProperty(\"--es-width\", `${full}px`);\n      const goal = open.current ? full : collapsed;\n      if (width.isAnimating()) animate(width, goal, motionTokens.spring.morph);\n      else width.jump(goal);\n      syncShape(width.get());\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [expandedWidth, width, syncShape]);\n\n  // Opening grows on the morph spring with a hint of overshoot; folding back settles without one, so the circle never dips below its own size.\n  const morphTo = (goal: number) => {\n    if (reduced) width.jump(goal);\n    else animate(width, goal, goal >= width.get() ? motionTokens.spring.morph : motionTokens.spring.smooth);\n  };\n\n  // Focus moves inside the same click, so touch keyboards open; the field is inert until this flush makes it focusable.\n  function expand() {\n    if (open.current) return;\n    open.current = true;\n    flushSync(() => {\n      setExpanded(true);\n      setFocused(true);\n    });\n    inputRef.current?.focus({ preventScroll: true });\n    morphTo(size.current.expanded);\n    onExpandedChange?.(true);\n  }\n\n  function collapse(returnFocus: boolean) {\n    if (!open.current) return;\n    open.current = false;\n    flushSync(() => {\n      setExpanded(false);\n      setFocused(false);\n      setQuery(\"\");\n      setActive(-1);\n    });\n    if (returnFocus) buttonRef.current?.focus({ preventScroll: true });\n    morphTo(size.current.collapsed);\n    onExpandedChange?.(false);\n  }\n\n  const trimmed = query.trim();\n  const results = useMemo(() => (trimmed ? rank(items, trimmed, maxResults) : []), [items, trimmed, maxResults]);\n  const mode: Mode | null = !trimmed\n    ? suggestions.length\n      ? \"suggestions\"\n      : null\n    : results.length\n      ? \"results\"\n      : \"empty\";\n  const groups = useMemo<Group[]>(\n    () =>\n      mode === \"suggestions\"\n        ? [{ name: suggestionsLabel, items: suggestions }]\n        : mode === \"results\"\n          ? groupBy(results, items)\n          : [],\n    [mode, suggestions, suggestionsLabel, results, items],\n  );\n  const flat = groups.flatMap((group) => group.items);\n  const activeIndex = Math.min(active, flat.length - 1);\n  const activeItem = flat[activeIndex];\n  const optionId = (item: ExpandingSearchItem) => `${id}-${mode}-${item.id}`;\n  const listboxId = `${id}-${mode}-listbox`;\n  const panelOpen = expanded && focused && mode !== null;\n  const announcement = !panelOpen\n    ? \"\"\n    : mode === \"empty\"\n      ? `No results for ${trimmed}`\n      : mode === \"results\"\n        ? `${flat.length} ${flat.length === 1 ? \"result\" : \"results\"}`\n        : `${flat.length} ${suggestionsLabel.toLocaleLowerCase()} ${flat.length === 1 ? \"item\" : \"items\"}`;\n\n  function choose(item: ExpandingSearchItem) {\n    collapse(true);\n    onSelect?.(item);\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      collapse(true);\n      return;\n    }\n    if (!panelOpen || !flat.length) return;\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      const step = event.key === \"ArrowDown\" ? 1 : -1;\n      keyboard.current = true;\n      setActive(activeIndex < 0 ? (step > 0 ? 0 : flat.length - 1) : (activeIndex + step + flat.length) % flat.length);\n    } else if (event.key === \"Enter\" && activeItem) {\n      event.preventDefault();\n      choose(activeItem);\n    }\n  }\n\n  const clearHidden = reduced\n    ? { opacity: 0 }\n    : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"group/es pointer-events-none relative h-control-md w-full min-w-control-md\", className)}\n      data-anchor={anchor}\n    >\n      <motion.div ref={shellRef} className={shellClass} data-expanded={expanded || undefined} style={{ width }}>\n        <button\n          ref={buttonRef}\n          type=\"button\"\n          className=\"absolute inset-0 z-1 m-0 rounded-[inherit] border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent] focus-visible:outline-none\"\n          aria-label={label}\n          hidden={expanded}\n          onClick={expand}\n        />\n        {/* The glass sits at the leading edge, so it is centred in the circle and rides that edge to its place in the open field. */}\n        <Search\n          className=\"pointer-events-none absolute top-[calc(50%-9px)] left-[calc(var(--control-height-md)/2-10px)]\"\n          width={18}\n          height={18}\n          strokeWidth={1.75}\n          aria-hidden=\"true\"\n        />\n        {/* The placeholder waits for the shape to start moving, then fades in; on close it leaves first. */}\n        <motion.input\n          ref={inputRef}\n          className=\"absolute inset-0 m-0 size-full border-0 bg-transparent py-0 pr-10 pl-[38px] text-(length:--text-sm) tracking-body text-ellipsis text-ink outline-none placeholder:text-ink-3 placeholder:opacity-100 inert:pointer-events-none\"\n          type=\"text\"\n          role=\"combobox\"\n          inert={!expanded}\n          value={query}\n          placeholder={placeholder ?? label}\n          aria-label={label}\n          aria-expanded={panelOpen}\n          aria-controls={panelOpen && mode !== \"empty\" ? listboxId : undefined}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={panelOpen && activeItem ? optionId(activeItem) : undefined}\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          enterKeyHint=\"search\"\n          initial={false}\n          animate={{ opacity: expanded ? 1 : 0 }}\n          transition={\n            expanded\n              ? { duration: reduced ? 0.15 : 0.2, ease: enter, delay: reduced ? 0 : 0.08 }\n              : { duration: reduced ? 0.1 : 0.08, ease: standard }\n          }\n          onChange={(event) => {\n            const next = event.target.value;\n            setQuery(next);\n            setActive(next.trim() ? 0 : -1);\n            keyboard.current = false;\n          }}\n          onFocus={() => setFocused(true)}\n          onBlur={() => {\n            // Switching windows keeps the search as it was; the field refocuses on return.\n            if (typeof document !== \"undefined\" && !document.hasFocus()) return;\n            setFocused(false);\n            if (open.current && !inputRef.current?.value.trim()) collapse(false);\n          }}\n          onKeyDown={handleKeyDown}\n        />\n        <AnimatePresence initial={false}>\n          {expanded && query && (\n            <motion.button\n              key=\"clear\"\n              type=\"button\"\n              tabIndex={-1}\n              className=\"absolute top-[calc(50%-14px)] right-[7px] z-1 m-0 grid size-7 place-items-center rounded-button border-0 bg-transparent p-0 text-ink-3 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-hover hover:text-ink motion-reduce:transition-none\"\n              aria-label=\"Clear search\"\n              onMouseDown={(event) => event.preventDefault()}\n              onClick={() => {\n                setQuery(\"\");\n                setActive(-1);\n                inputRef.current?.focus({ preventScroll: true });\n              }}\n              initial={clearHidden}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={{\n                ...clearHidden,\n                transition: { duration: reduced ? 0.1 : motionTokens.duration.instant, ease: standard },\n              }}\n              transition={\n                reduced\n                  ? { duration: 0.15 }\n                  : {\n                      ...motionTokens.spring.snappy,\n                      opacity: { duration: motionTokens.duration.fast },\n                      filter: { duration: motionTokens.duration.fast },\n                    }\n              }\n            >\n              <X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </motion.div>\n      <AnimatePresence>\n        {panelOpen && (\n          <Panel key=\"panel\" width={width} reduced={reduced}>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.div\n                key={mode}\n                className=\"relative\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: 0.1, ease: standard } }}\n                transition={{ duration: reduced ? 0.15 : motionTokens.duration.fast, ease: enter }}\n              >\n                {mode === \"empty\" ? (\n                  <div className=\"grid gap-0.5 px-3 pt-3 pb-3.5\">\n                    <p className=\"m-0 text-sm leading-body font-medium [overflow-wrap:anywhere] text-ink\">\n                      No results for “{trimmed}”\n                    </p>\n                    <p className=\"m-0 text-xs leading-body text-ink-3\">{emptyHint}</p>\n                  </div>\n                ) : (\n                  <Listbox\n                    id={listboxId}\n                    label={mode === \"suggestions\" ? suggestionsLabel : `Results for ${trimmed}`}\n                    groups={groups}\n                    query={mode === \"results\" ? trimmed : \"\"}\n                    activeIndex={activeIndex}\n                    optionId={optionId}\n                    reduced={reduced}\n                    keyboard={keyboard}\n                    onHover={setActive}\n                    onChoose={choose}\n                  />\n                )}\n              </motion.div>\n            </AnimatePresence>\n          </Panel>\n        )}\n      </AnimatePresence>\n      <span\n        className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\"\n        role=\"status\"\n        aria-live=\"polite\"\n      >\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nexport default ExpandingSearch;\n",
      "type": "registry:component",
      "target": "@components/iris/expanding-search/expanding-search.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/expanding-search",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "ExpandingSearch",
    "tags": [
      "search",
      "field",
      "motion"
    ],
    "docs": "https://irisui.dev/components/expanding-search"
  }
}
