{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-bar",
  "title": "Search bar",
  "description": "A pill search field with a cycling prompt that unfolds into a staged filter panel, with an optional plain-English ask field and removable filter chips.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/search-bar/search-bar.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotionConfig,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { ArrowRight, Check, ChevronLeft, SlidersHorizontal, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A pill that unfolds into a filter panel. Collapsed it is one row: a prompt\n * with a cycling subject, a submit disc that appears once there is something to\n * search, and a filters toggle. Open, that row stays exactly where it was and a\n * panel grows underneath it, so opening reads as the pill extending rather than\n * a card arriving.\n *\n * Searching is explicit. Typing and picking filters only stage them; nothing is\n * reported until Enter or the disc is pressed, and then `onSubmit` receives the\n * query and every staged filter at once. The disc wears a rolling square for at\n * least `minSpinMs` (and for as long as a returned promise is pending), because\n * a search that resolves in zero frames reads as broken.\n *\n * The bar sits in a fixed-height frame and the open card floats over whatever\n * is below it, so the page never jumps when the panel opens.\n */\n\n/** Collapsed row height; also the pill's radius, so the pill and the open\n *  card's corner are the same number and the morph has nothing to interpolate. */\nconst ROW = 64;\n\n/** Body of the open panel. Fixed, so swapping views inside it never resizes it. */\nconst PANEL_H = \"h-[280px]\";\n\nconst SUBJECT_HOLD = 2400;\n\nconst EASE = [0.2, 0.7, 0.2, 1] as const;\n\nconst DEFAULT_SUBJECTS = [\"Charities\", \"Companies\", \"Grants\", \"Clients\"] as const;\n\nconst PANEL_STAGGER: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.05, delayChildren: 0.14 } },\n};\n\n/**\n * Rows inside the card rise on opacity and y only, never `filter`: a filter on\n * a descendant of a `backdrop-filter` element breaks the parent's backdrop\n * sampling in Chrome and Safari, and the frost goes flat.\n */\nconst ITEM: Variants = {\n  hidden: { opacity: 0, y: 8 },\n  show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: EASE } },\n};\n\nconst OPTION: Variants = {\n  hidden: { opacity: 0, y: 8 },\n  show: (index: number = 0) => ({\n    opacity: 1,\n    y: 0,\n    transition: { duration: 0.3, ease: EASE, delay: Math.min(index * 0.025, 0.3) },\n  }),\n};\n\nconst NO_SCROLLBAR = \"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]\";\n\n/** A 44px hit area on touch screens around a control that stays visually small. */\nconst TOUCH =\n  \"pointer-coarse:relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Accents. Lead on a light page; lime on a dark one, where the disc, the\n * spinner, checks and focus rings become the brand moment.\n */\nconst FOCUS =\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lead dark:focus-visible:outline-lime\";\nconst FIELD_FOCUS = \"focus-visible:ring-2 focus-visible:ring-go dark:focus-visible:ring-lime\";\nconst DISC =\n  \"bg-lead text-on-solid hover:bg-lead-mid dark:bg-lime dark:text-on-lime dark:hover:bg-lime-hover\";\nconst SPINNER =\n  \"bg-lead shadow-[0_0_10px_var(--lead)] dark:bg-lime dark:shadow-[0_0_10px_var(--lime)]\";\nconst ACCENT_TEXT = \"text-go dark:text-lime\";\nconst SELECTED = \"bg-lime/40 text-go dark:bg-on-solid/15 dark:text-lime\";\nconst SELECTED_BTN =\n  \"bg-lime/40 text-go ring-1 ring-go/40 dark:bg-on-solid/20 dark:text-lime dark:ring-lime/50\";\nconst TOGGLE = \"bg-ink/8 text-ink hover:bg-ink/12\";\nconst ROW_HOVER = \"hover:bg-ink/5 hover:text-ink\";\n\nexport type FilterOption = {\n  label: string;\n  value: string;\n  /** Optional swatch colour (any CSS colour, ideally a token: `var(--go)`). */\n  colour?: string;\n};\n\nexport type FilterCategory = {\n  /** Shown as the category row and used as the chip's category. */\n  label: string;\n  /** What can be picked. Ignored when `freeText` is set. */\n  options?: FilterOption[];\n  /**\n   * Make this a typed filter: the panel shows a text field instead of a list.\n   * At most one value is staged per free-text category.\n   */\n  freeText?: {\n    placeholder?: string;\n    hint?: ReactNode;\n    maxLength?: number;\n    /** Button label before a value is staged. */\n    addLabel?: string;\n    /** Button label once one is. */\n    updateLabel?: string;\n  };\n};\n\nexport type ActiveFilter = FilterOption & { category: string };\n\nexport type SearchBarAsk = {\n  /** Row label, e.g. \"Ask in plain English\". */\n  label: string;\n  placeholder?: string;\n  defaultValue?: string;\n  maxLength?: number;\n  /** Runs on Enter or the ask disc. Return a promise to keep the spinner up\n   *  until it settles; the panel closes when it does. */\n  onAsk: (question: string) => void | Promise<unknown>;\n  /** The host's own work for the question is running. */\n  pending?: boolean;\n};\n\nexport type SearchBarProps = {\n  className?: string;\n  /** Controlled query text. */\n  value?: string;\n  /** Uncontrolled starting query. */\n  defaultValue?: string;\n  /** Every keystroke. Nothing is searched until submit. */\n  onValueChange?: (value: string) => void;\n  /** The static half of the prompt. */\n  placeholder?: string;\n  /** Words that cycle after the prompt while the field is empty. Name what this page holds. */\n  subjects?: readonly string[];\n  /** Filter groups. Omit and the filters toggle is not rendered. */\n  categories?: FilterCategory[];\n  /** Controlled staged filters. */\n  filters?: ActiveFilter[];\n  /** Uncontrolled starting filters, so a filtered page renders its own chips. */\n  defaultFilters?: ActiveFilter[];\n  /** Staged filters changed (picked, removed). Nothing is searched until submit. */\n  onFiltersChange?: (filters: ActiveFilter[]) => void;\n  /**\n   * Enter, the disc, or \"Clear filters\" (with an empty query and no filters).\n   * Return a promise to keep the spinner up until it settles.\n   */\n  onSubmit?: (query: string, filters: ActiveFilter[]) => void | Promise<unknown>;\n  /** The host's own work is running; the disc shows the rolling square. */\n  pending?: boolean;\n  /** Shortest the rolling square plays after a submit. */\n  minSpinMs?: number;\n  /** A second, descriptive field at the top of the filter panel. */\n  ask?: SearchBarAsk;\n  /** Show staged filters as removable chips under the pill. */\n  chipsBelow?: boolean;\n  /** Accessible label for the query field. Defaults to the placeholder. */\n  \"aria-label\"?: string;\n};\n\nfunction rankOption(label: string, query: string): number {\n  const l = label.toLowerCase();\n  if (l.startsWith(query)) return 0;\n  if (l.includes(` ${query}`)) return 1;\n  return l.includes(query) ? 2 : -1;\n}\n\nfunction Spinner() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\"block h-4.5 w-4.5 animate-spin rounded-[4px]\", SPINNER)}\n      style={{ animationDuration: \"2.5s\" }}\n    />\n  );\n}\n\nexport function SearchBar({\n  className,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  placeholder = \"I want to learn about\",\n  subjects = DEFAULT_SUBJECTS,\n  categories,\n  filters: filtersProp,\n  defaultFilters = [],\n  onFiltersChange,\n  onSubmit,\n  pending = false,\n  minSpinMs = 1500,\n  ask,\n  chipsBelow = true,\n  \"aria-label\": ariaLabel,\n}: SearchBarProps) {\n  const reduced = useReducedMotionConfig() ?? false;\n  const t = (transition: Transition): Transition => (reduced ? { duration: 0 } : transition);\n\n  const [innerQuery, setInnerQuery] = useState(defaultValue);\n  const query = value ?? innerQuery;\n  const setQuery = (next: string) => {\n    if (value === undefined) setInnerQuery(next);\n    onValueChange?.(next);\n  };\n\n  const [innerFilters, setInnerFilters] = useState<ActiveFilter[]>(defaultFilters);\n  const filters = filtersProp ?? innerFilters;\n  const setFilters = (next: ActiveFilter[]) => {\n    if (filtersProp === undefined) setInnerFilters(next);\n    onFiltersChange?.(next);\n  };\n\n  const [open, setOpen] = useState(false);\n  const [subject, setSubject] = useState(0);\n  const [activeCategory, setActiveCategory] = useState<string | null>(null);\n  const [optionQuery, setOptionQuery] = useState(\"\");\n  const [freeTextValue, setFreeTextValue] = useState(\"\");\n  const [spinning, setSpinning] = useState(false);\n  const [askValue, setAskValue] = useState(ask?.defaultValue ?? \"\");\n  const [askRunning, setAskRunning] = useState(false);\n  const askPending = askRunning || Boolean(ask?.pending);\n  const busy = spinning || pending;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const panelBodyRef = useRef<HTMLDivElement>(null);\n  const panelId = useId();\n  const askId = useId();\n\n  const hasFilters = Boolean(categories && categories.length > 0);\n  const typing = query.length > 0;\n\n  /**\n   * The card animates to a measured pixel height rather than \"auto\": swapping\n   * views inside an open panel changes nothing Motion can see when the target\n   * stays the string \"auto\", so the box would snap instead of tween.\n   */\n  const [panelBodyHeight, setPanelBodyHeight] = useState<number | null>(null);\n  useEffect(() => {\n    const body = panelBodyRef.current;\n    if (!open || !body) return;\n    const observer = new ResizeObserver(([entry]) => setPanelBodyHeight(entry.contentRect.height));\n    observer.observe(body);\n    return () => observer.disconnect();\n  }, [open]);\n\n  // The subject cycles only while there is nothing else in the row to read.\n  useEffect(() => {\n    if (typing || open || reduced || subjects.length < 2) return;\n    const id = setInterval(() => setSubject((i) => (i + 1) % subjects.length), SUBJECT_HOLD);\n    return () => clearInterval(id);\n  }, [open, typing, reduced, subjects.length]);\n\n  // Pointerdown, not click: a drag that starts inside and ends outside must not\n  // close the panel out from under the cursor.\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (event: PointerEvent) => {\n      if (rootRef.current?.contains(event.target as Node)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open]);\n\n  const close = () => {\n    setOpen(false);\n    setTimeout(() => {\n      setActiveCategory(null);\n      setOptionQuery(\"\");\n    }, 300);\n  };\n\n  const submit = (nextFilters = filters, q = query, closePanel = true) => {\n    if (busy) return;\n    if (closePanel) close();\n    inputRef.current?.blur();\n    setSpinning(true);\n    const started = Date.now();\n    const done = () => {\n      const rest = reduced ? 0 : Math.max(0, minSpinMs - (Date.now() - started));\n      setTimeout(() => setSpinning(false), rest);\n    };\n    Promise.resolve(onSubmit?.(q, nextFilters)).then(done, (error: unknown) => {\n      done();\n      throw error;\n    });\n  };\n\n  const submitAsk = () => {\n    const question = askValue.trim();\n    if (!ask || !question || askPending) return;\n    setAskRunning(true);\n    Promise.resolve(ask.onAsk(question)).then(\n      () => {\n        setAskRunning(false);\n        close();\n      },\n      (error: unknown) => {\n        setAskRunning(false);\n        throw error;\n      },\n    );\n  };\n\n  /** Empties the chips and the query, then submits: clearing is an explicit\n   *  act, so the host is told, rather than left filtering by invisible chips. */\n  const clearAll = () => {\n    setFilters([]);\n    setQuery(\"\");\n    submit([], \"\", false);\n  };\n\n  const removeFilter = (category: string, optionValue: string) =>\n    setFilters(filters.filter((f) => !(f.category === category && f.value === optionValue)));\n\n  const toggleOption = (category: string, option: FilterOption) => {\n    const selected = filters.some((f) => f.category === category && f.value === option.value);\n    setFilters(\n      selected\n        ? filters.filter((f) => !(f.category === category && f.value === option.value))\n        : [...filters, { category, ...option }],\n    );\n  };\n\n  const stageFreeText = (category: string): ActiveFilter[] => {\n    const trimmed = freeTextValue.trim();\n    const staged = [\n      ...filters.filter((f) => f.category !== category),\n      ...(trimmed ? [{ category, label: trimmed, value: trimmed }] : []),\n    ];\n    setFilters(staged);\n    setFreeTextValue(\"\");\n    return staged;\n  };\n\n  const active = categories?.find((c) => c.label === activeCategory);\n  const trimmedOptionQuery = optionQuery.trim();\n\n  const activeOptions = useMemo(() => {\n    const all = active?.options ?? [];\n    const q = trimmedOptionQuery.toLowerCase();\n    if (!q) return all;\n    // The source index breaks ties, so equally ranked options keep the host's order.\n    return all\n      .map((option, index) => ({ option, index, rank: rankOption(option.label, q) }))\n      .filter((entry) => entry.rank >= 0)\n      .sort((a, b) => a.rank - b.rank || a.index - b.index)\n      .map((entry) => entry.option);\n  }, [active, trimmedOptionQuery]);\n\n  const longestSubject = subjects.reduce((a, b) => (b.length > a.length ? b : a), \"\");\n\n  const backButton = (\n    <button\n      type=\"button\"\n      onClick={() => {\n        setActiveCategory(null);\n        setOptionQuery(\"\");\n      }}\n      className={cn(\n        \"font-body flex shrink-0 items-center gap-1 rounded-panel px-3 py-2 text-[15px] font-medium text-dim transition-colors\",\n        ROW_HOVER,\n        FOCUS,\n      )}\n    >\n      <ChevronLeft className=\"h-4 w-4\" />\n      Back\n    </button>\n  );\n\n  const chip = (filter: ActiveFilter) => (\n    <motion.span\n      key={`${filter.category}-${filter.value}`}\n      initial={{ opacity: 0, scale: 0.8, y: -10 }}\n      animate={{ opacity: 1, scale: 1, y: 0 }}\n      exit={{ opacity: 0, scale: 0.8, y: -10 }}\n      transition={t({ duration: 0.2, ease: EASE })}\n      layout={!reduced}\n      className=\"font-body flex max-w-full min-w-0 items-center gap-1.5 rounded-full bg-ground px-3 py-1.5 text-[14px] font-medium text-ink shadow-sm\"\n    >\n      {filter.colour && (\n        <span\n          aria-hidden=\"true\"\n          className=\"h-2 w-2 shrink-0 rounded-full\"\n          style={{ backgroundColor: filter.colour }}\n        />\n      )}\n      <span className=\"min-w-0 truncate\">{filter.label}</span>\n      <button\n        type=\"button\"\n        aria-label={`Remove ${filter.label} filter`}\n        onClick={() => removeFilter(filter.category, filter.value)}\n        className={cn(\n          \"flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-paper text-dim transition-colors hover:bg-paper-sunk\",\n          TOUCH,\n          FOCUS,\n        )}\n      >\n        <X className=\"h-3 w-3\" />\n      </button>\n    </motion.span>\n  );\n\n  return (\n    <div className={cn(\"flex w-full max-w-[600px] flex-col gap-3\", className)}>\n      <div className=\"relative z-50 w-full\" style={{ height: ROW }}>\n        <div\n          ref={rootRef}\n          className=\"absolute inset-x-0 top-0 flex flex-col\"\n          onKeyDown={(event) => {\n            if (event.key === \"Escape\" && open) {\n              event.stopPropagation();\n              close();\n              inputRef.current?.blur();\n            }\n          }}\n        >\n          <motion.div\n            className=\"relative w-full overflow-hidden bg-lead-wash shadow-[inset_0_1px_0_color-mix(in_oklab,var(--card)_30%,transparent)]\"\n            style={{ borderRadius: ROW / 2 }}\n            initial={false}\n            animate={{ height: open ? (panelBodyHeight != null ? ROW + panelBodyHeight : \"auto\") : ROW }}\n            transition={t({ duration: 0.7, ease: EASE })}\n          >\n            {/* The frost lives on a childless layer: `backdrop-filter` is defeated\n                by any `filter` in its own subtree, and a leaf has none. */}\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 z-0 rounded-[inherit] backdrop-blur-[3px]\"\n            />\n            {/* Open, the card steps from the closed wash to a floating surface.\n                An opacity crossfade rather than a colour tween, so it follows\n                the theme and the mode without re-resolving anything. */}\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 z-0 bg-popover\"\n              initial={false}\n              animate={{ opacity: open ? 1 : 0 }}\n              transition={t({ duration: 0.7, ease: EASE })}\n            />\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 z-0 bg-on-solid/8\"\n              initial={false}\n              animate={{ opacity: open ? 1 : 0 }}\n              transition={t({ duration: 0.3, ease: EASE })}\n            />\n\n            {/* The prompt row. */}\n            <div\n              className=\"relative z-20 flex items-center bg-lead-wash pr-2 pl-4 sm:pr-3 sm:pl-7\"\n              style={{ height: ROW, borderRadius: ROW / 2 }}\n            >\n              <div className=\"relative mr-2 min-w-0 flex-1 overflow-hidden sm:mr-3\">\n                <input\n                  ref={inputRef}\n                  type=\"search\"\n                  value={query}\n                  aria-label={ariaLabel ?? `${placeholder}…`}\n                  onChange={(event) => setQuery(event.target.value)}\n                  onKeyDown={(event) => {\n                    if (event.key === \"Enter\") {\n                      event.preventDefault();\n                      event.stopPropagation();\n                      submit();\n                    }\n                  }}\n                  className=\"font-body w-full bg-transparent text-[15px] text-ink caret-lead outline-none focus-visible:outline-none sm:text-base dark:caret-lime [&::-webkit-search-cancel-button]:hidden\"\n                />\n\n                {/* Over the empty field rather than in `placeholder`, which can\n                    carry one colour and cannot animate. Click-through. */}\n                {!typing && (\n                  <div\n                    aria-hidden=\"true\"\n                    className=\"font-body pointer-events-none absolute inset-0 flex items-center gap-[0.4ch] text-[15px] whitespace-nowrap sm:text-base\"\n                  >\n                    <span className=\"min-w-0 truncate text-dim\">{placeholder}</span>\n                    {subjects.length > 0 && (\n                      <span className=\"relative hidden sm:inline\">\n                        {/* Reserves the widest subject so the row never jumps. */}\n                        <span className=\"invisible\">{longestSubject}</span>\n                        <AnimatePresence initial={false} mode=\"popLayout\">\n                          <motion.span\n                            key={subjects[subject % subjects.length]}\n                            className=\"absolute inset-0 text-ink\"\n                            initial={{ opacity: 0, y: 10, filter: \"blur(6px)\" }}\n                            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                            exit={{ opacity: 0, y: -10, filter: \"blur(6px)\" }}\n                            transition={t({ duration: 0.45, ease: EASE })}\n                          >\n                            {subjects[subject % subjects.length]}\n                          </motion.span>\n                        </AnimatePresence>\n                      </span>\n                    )}\n                  </div>\n                )}\n              </div>\n\n              <AnimatePresence>\n                {(typing || filters.length > 0 || busy) && (\n                  <motion.div\n                    initial={{ width: 0, opacity: 0, scale: 0.8 }}\n                    animate={{ width: 30, opacity: 1, scale: 1 }}\n                    exit={{ width: 0, opacity: 0, scale: 0.8 }}\n                    transition={t({ duration: 0.2, ease: EASE })}\n                    className=\"shrink-0 overflow-visible\"\n                  >\n                    <button\n                      type=\"button\"\n                      aria-label={busy ? \"Searching…\" : \"Search\"}\n                      title={busy ? \"Searching…\" : \"Press Enter or click to search\"}\n                      disabled={busy}\n                      onClick={() => submit()}\n                      className={cn(\n                        \"grid h-8 w-8 shrink-0 place-items-center rounded-full transition-all\",\n                        TOUCH,\n                        FOCUS,\n                        busy ? \"bg-transparent\" : DISC,\n                      )}\n                    >\n                      {busy ? <Spinner /> : <ArrowRight className=\"h-4 w-4\" />}\n                    </button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n\n              {hasFilters && (\n                <div className=\"ml-2 flex shrink-0 items-center gap-1 sm:ml-3 sm:gap-2\">\n                  <button\n                    type=\"button\"\n                    aria-label={open ? \"Close filters\" : \"Open filters\"}\n                    aria-expanded={open}\n                    aria-controls={open ? panelId : undefined}\n                    onClick={() => {\n                      if (open) {\n                        close();\n                        inputRef.current?.blur();\n                      } else {\n                        setOpen(true);\n                      }\n                    }}\n                    className={cn(\n                      \"grid h-8 w-8 shrink-0 place-items-center rounded-full transition-colors\",\n                      TOGGLE,\n                      TOUCH,\n                      FOCUS,\n                    )}\n                  >\n                    {open ? <X className=\"h-5 w-5\" /> : <SlidersHorizontal className=\"h-4 w-4\" />}\n                  </button>\n                </div>\n              )}\n            </div>\n\n            <AnimatePresence initial={false}>\n              {open && categories && (\n                <motion.div\n                  key=\"panel\"\n                  id={panelId}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: t({ duration: 0.18, ease: EASE }) }}\n                  transition={t({ duration: 0.5, ease: EASE, delay: 0.2 })}\n                  className=\"relative z-10\"\n                >\n                  <div ref={panelBodyRef}>\n                    <AnimatePresence mode=\"wait\">\n                      {!active ? (\n                        <motion.ul\n                          key=\"categories\"\n                          className={cn(\"flex flex-col gap-1 overflow-y-auto px-4 py-4\", PANEL_H, NO_SCROLLBAR)}\n                          variants={PANEL_STAGGER}\n                          initial=\"hidden\"\n                          animate=\"show\"\n                          exit={{ opacity: 0, transition: t({ duration: 0.15 }) }}\n                        >\n                          {ask && (\n                            <motion.li key=\"__ask\" variants={ITEM} className=\"mb-1\">\n                              <div className=\"rounded-panel bg-ink/5 px-3 py-2\">\n                                <label\n                                  htmlFor={askId}\n                                  className=\"font-body block text-[13px] font-medium text-dim\"\n                                >\n                                  {ask.label}\n                                </label>\n                                <div className=\"mt-1.5 flex items-center gap-2\">\n                                  <input\n                                    id={askId}\n                                    type=\"text\"\n                                    value={askValue}\n                                    maxLength={ask.maxLength}\n                                    placeholder={ask.placeholder}\n                                    onChange={(event) => setAskValue(event.target.value)}\n                                    onKeyDown={(event) => {\n                                      if (event.key === \"Enter\") {\n                                        event.preventDefault();\n                                        event.stopPropagation();\n                                        submitAsk();\n                                      }\n                                    }}\n                                    className=\"font-body min-w-0 flex-1 bg-transparent text-base text-ink caret-lead outline-none placeholder:text-faint dark:caret-lime\"\n                                  />\n                                  <button\n                                    type=\"button\"\n                                    aria-label={askPending ? \"Searching…\" : ask.label}\n                                    aria-busy={askPending}\n                                    disabled={askValue.trim().length === 0 || askPending}\n                                    onClick={submitAsk}\n                                    className={cn(\n                                      \"grid h-8 w-8 shrink-0 place-items-center rounded-full transition-all\",\n                                      TOUCH,\n                                      FOCUS,\n                                      askPending\n                                        ? \"bg-transparent\"\n                                        : cn(DISC, \"disabled:cursor-not-allowed disabled:opacity-40\"),\n                                    )}\n                                  >\n                                    {askPending ? <Spinner /> : <ArrowRight className=\"h-4 w-4\" />}\n                                  </button>\n                                </div>\n                              </div>\n                            </motion.li>\n                          )}\n\n                          {categories.map((category) => {\n                            const count = filters.filter((f) => f.category === category.label).length;\n                            return (\n                              <motion.li key={category.label} variants={ITEM}>\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setActiveCategory(category.label);\n                                    setOptionQuery(\"\");\n                                    // A typed filter pre-fills from its staged chip,\n                                    // so reopening edits the term instead of losing it.\n                                    setFreeTextValue(\n                                      filters.find((f) => f.category === category.label)?.value ?? \"\",\n                                    );\n                                  }}\n                                  className={cn(\n                                    \"font-body flex w-full items-center justify-between rounded-panel px-3 py-2 text-left text-lg font-medium text-ink transition-colors\",\n                                    ROW_HOVER,\n                                    FOCUS,\n                                  )}\n                                >\n                                  <span>{category.label}</span>\n                                  {count > 0 && (\n                                    <span className=\"rounded-full bg-lime px-2 py-0.5 text-xs font-bold text-on-lime\">\n                                      {count}\n                                    </span>\n                                  )}\n                                </button>\n                              </motion.li>\n                            );\n                          })}\n                        </motion.ul>\n                      ) : active.freeText ? (\n                        <motion.div\n                          key={`free-${active.label}`}\n                          className={cn(\"flex flex-col px-4 py-4\", PANEL_H)}\n                          variants={PANEL_STAGGER}\n                          initial=\"hidden\"\n                          animate=\"show\"\n                          exit={{ opacity: 0, transition: t({ duration: 0.15 }) }}\n                        >\n                          <motion.div variants={ITEM} className=\"mb-4 flex shrink-0 items-center gap-2\">\n                            {backButton}\n                          </motion.div>\n                          <motion.div variants={ITEM} className=\"flex flex-1 flex-col gap-3\">\n                            <input\n                              type=\"text\"\n                              aria-label={active.label}\n                              value={freeTextValue}\n                              placeholder={active.freeText.placeholder}\n                              maxLength={active.freeText.maxLength}\n                              onChange={(event) => setFreeTextValue(event.target.value)}\n                              onKeyDown={(event) => {\n                                if (event.key === \"Enter\") {\n                                  // Submit what was just staged, not this render's\n                                  // pre-stage filters.\n                                  event.preventDefault();\n                                  event.stopPropagation();\n                                  submit(stageFreeText(active.label));\n                                }\n                              }}\n                              className={cn(\n                                \"font-body w-full rounded-panel bg-ink/5 px-4 py-2.5 text-[15px] text-ink outline-none placeholder:text-faint\",\n                                FIELD_FOCUS,\n                              )}\n                            />\n                            {active.freeText.hint && (\n                              <p className=\"font-body text-[13px] leading-[1.6] text-dim\">{active.freeText.hint}</p>\n                            )}\n                            <div className=\"mt-auto flex items-center justify-end gap-2\">\n                              {(() => {\n                                const staged = filters.some((f) => f.category === active.label);\n                                return (\n                                  <button\n                                    type=\"button\"\n                                    disabled={!freeTextValue.trim()}\n                                    onClick={() => submit(stageFreeText(active.label))}\n                                    className={cn(\n                                      \"font-body inline-flex items-center gap-1.5 rounded-panel px-4 py-2 text-[13px] font-bold shadow-xs transition-all disabled:cursor-not-allowed disabled:opacity-40\",\n                                      FOCUS,\n                                      staged ? SELECTED_BTN : \"bg-lime text-on-lime hover:bg-lime-hover\",\n                                    )}\n                                  >\n                                    <Check className=\"h-3.5 w-3.5\" />\n                                    <span>\n                                      {staged\n                                        ? (active.freeText.updateLabel ?? \"Update filter\")\n                                        : (active.freeText.addLabel ?? \"Add filter\")}\n                                    </span>\n                                  </button>\n                                );\n                              })()}\n                            </div>\n                          </motion.div>\n                        </motion.div>\n                      ) : (\n                        <motion.div\n                          key={`options-${active.label}`}\n                          className={cn(\"flex flex-col pt-4\", PANEL_H)}\n                          variants={PANEL_STAGGER}\n                          initial=\"hidden\"\n                          animate=\"show\"\n                          exit={{ opacity: 0, transition: t({ duration: 0.15 }) }}\n                        >\n                          <motion.div variants={ITEM} className=\"mb-4 flex shrink-0 items-center gap-2 px-4\">\n                            {backButton}\n                            <input\n                              type=\"search\"\n                              aria-label={`Search ${active.label.toLowerCase()}`}\n                              placeholder={`Search ${active.label.replace(/^filter by /i, \"\").toLowerCase()}…`}\n                              value={optionQuery}\n                              onChange={(event) => setOptionQuery(event.target.value)}\n                              onKeyDown={(event) => {\n                                if (event.key === \"Enter\") {\n                                  event.preventDefault();\n                                  event.stopPropagation();\n                                  submit();\n                                }\n                              }}\n                              className={cn(\n                                \"font-body min-w-0 flex-1 rounded-panel bg-ink/5 px-4 py-2 text-[15px] text-ink outline-none placeholder:text-faint [&::-webkit-search-cancel-button]:hidden\",\n                                FIELD_FOCUS,\n                              )}\n                            />\n                          </motion.div>\n\n                          <motion.ul\n                            variants={{ hidden: {}, show: {} }}\n                            className={cn(\"flex flex-1 flex-col gap-1 overflow-y-auto px-4 pb-4\", NO_SCROLLBAR)}\n                          >\n                            {activeOptions.map((option, index) => {\n                              const selected = filters.some(\n                                (f) => f.category === active.label && f.value === option.value,\n                              );\n                              return (\n                                <motion.li key={option.value} variants={OPTION} custom={index}>\n                                  <button\n                                    type=\"button\"\n                                    aria-pressed={selected}\n                                    onClick={() => toggleOption(active.label, option)}\n                                    className={cn(\n                                      \"font-body flex w-full items-center justify-between rounded-panel px-3 py-2 text-lg font-semibold transition-colors\",\n                                      FOCUS,\n                                      selected ? SELECTED : cn(\"text-ink\", ROW_HOVER),\n                                    )}\n                                  >\n                                    <span className=\"flex items-center gap-2\">\n                                      {option.colour && (\n                                        <span\n                                          aria-hidden=\"true\"\n                                          className=\"h-2.5 w-2.5 shrink-0 rounded-full\"\n                                          style={{ backgroundColor: option.colour }}\n                                        />\n                                      )}\n                                      {option.label}\n                                    </span>\n                                    {selected && <Check className={cn(\"h-4 w-4\", ACCENT_TEXT)} />}\n                                  </button>\n                                </motion.li>\n                              );\n                            })}\n\n                            {/* Its own initial/animate, never the staggered variant:\n                                an empty result must not wait on orchestration, or it\n                                looks identical to a search that has not run. */}\n                            {activeOptions.length === 0 && (\n                              <motion.li\n                                initial={{ opacity: 0 }}\n                                animate={{ opacity: 1 }}\n                                transition={t({ duration: 0.2, ease: EASE })}\n                                className=\"font-body px-3 py-3 text-[15px] text-dim\"\n                                role=\"status\"\n                              >\n                                {trimmedOptionQuery\n                                  ? `No match for “${trimmedOptionQuery}”.`\n                                  : \"Nothing to filter by here.\"}\n                              </motion.li>\n                            )}\n                          </motion.ul>\n                        </motion.div>\n                      )}\n                    </AnimatePresence>\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </motion.div>\n        </div>\n      </div>\n\n      {/* Under the pill, never above it: a chip row above would push the pill\n          down the moment a filter is picked. The margin grows with the open\n          card, on the card's own transition, so the row rides down to sit just\n          past the card's real bottom edge and back up in step. */}\n      {chipsBelow && (\n        <motion.div\n          initial={false}\n          animate={{ marginTop: open ? (panelBodyHeight ?? 0) : 0 }}\n          transition={t({ duration: 0.7, ease: EASE })}\n          className=\"relative z-[60] flex flex-wrap items-center gap-2 px-2 empty:hidden\"\n        >\n          <AnimatePresence>{filters.map(chip)}</AnimatePresence>\n          {(filters.length > 0 || typing) && (\n            <button\n              type=\"button\"\n              onClick={clearAll}\n              className={cn(\"font-body ml-1 text-[13px] font-medium text-faint transition-colors hover:text-ink\", FOCUS)}\n            >\n              Clear filters\n            </button>\n          )}\n        </motion.div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Reach for it as the one search control at the top of a list page, where people combine a name search with several filters and submit them together. Not for instant as-you-type filtering, inline table search, or a compact toolbar field: nothing is reported until Enter or the disc is pressed, and the open panel needs about 350px of room below it.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/brand/search-bar.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
