{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cascader",
  "type": "registry:ui",
  "title": "Cascader",
  "description": "Nested columns that grow in beside each other, with search across every path.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/cascader.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown, ChevronLeft, ChevronRight, Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type CascaderOption = {\n  value: string;\n  label: string;\n  /** Quiet text at the end of the row: a count, a code, a status. */\n  hint?: string;\n  disabled?: boolean;\n  children?: CascaderOption[];\n};\n\n/** The option objects along a path of values. Stops at the first value that doesn't resolve. */\nexport function resolveCascaderPath(options: CascaderOption[], path: string[]) {\n  const out: CascaderOption[] = [];\n  let list: CascaderOption[] | undefined = options;\n  for (const v of path) {\n    const hit: CascaderOption | undefined = list?.find((o) => o.value === v);\n    if (!hit) break;\n    out.push(hit);\n    list = hit.children;\n  }\n  return out;\n}\n\n/** Every selectable path in the tree, for search. Leaves only, unless parents are selectable too. */\nexport function flattenCascader(options: CascaderOption[], withParents = false) {\n  const out: CascaderOption[][] = [];\n  const walk = (list: CascaderOption[], trail: CascaderOption[]) => {\n    for (const o of list) {\n      const next = [...trail, o];\n      const leaf = !o.children?.length;\n      if (leaf || withParents) out.push(next);\n      if (!leaf) walk(o.children!, next);\n    }\n  };\n  walk(options, []);\n  return out;\n}\n\nconst fold = (s: string) => s.normalize(\"NFD\").replace(/\\p{Diacritic}/gu, \"\").toLowerCase();\n\n// Below this width the columns can't sit side by side, so they stack and slide.\nconst NARROW = \"(max-width: 639px)\";\nfunction useNarrow() {\n  return useSyncExternalStore(\n    (cb) => {\n      const m = window.matchMedia(NARROW);\n      m.addEventListener(\"change\", cb);\n      return () => m.removeEventListener(\"change\", cb);\n    },\n    () => window.matchMedia(NARROW).matches,\n    () => false,\n  );\n}\n\nconst COL = 184; // column width\nconst ROW = 32; // option height\nconst MAX_ROWS = 8;\n\ntype Via = \"pointer\" | \"keyboard\";\n\nexport type CascaderProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"onChange\" | \"children\"> & {\n  options: CascaderOption[];\n  /** The chosen path of values, root first. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (path: string[], options: CascaderOption[]) => void;\n  placeholder?: string;\n  /** A name per column (\"Region\", \"Country\", \"City\"). Shown as column headers and used as each list's label. */\n  levels?: string[];\n  /** A search field that finds any selectable path by any of its labels. */\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  /** Let a parent be the answer (\"All of Europe\"), not only a leaf. */\n  selectParents?: boolean;\n  /** Open a parent's column on hover as well as on press. */\n  expandOn?: \"click\" | \"hover\";\n  size?: \"sm\" | \"md\";\n  invalid?: boolean;\n  /** Submits the chosen path joined with \"/\". */\n  name?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nexport function Cascader({\n  options,\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  placeholder = \"Choose…\",\n  levels,\n  searchable = false,\n  searchPlaceholder = \"Search\",\n  selectParents = false,\n  expandOn = \"click\",\n  size = \"md\",\n  invalid = false,\n  name,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled,\n  className,\n  ...rest\n}: CascaderProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const narrow = useNarrow();\n  const [value, setValue] = useControllableState<string[]>({ value: valueProp, defaultValue });\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n\n  // The path being browsed, which can run ahead of the committed value.\n  const [trail, setTrail] = useState<string[]>(value);\n  const [view, setView] = useState(0); // the column shown when stacked\n  const [dir, setDir] = useState(1);\n  const [via, setVia] = useState<Via>(\"pointer\");\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n\n  const initialRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const pendingFocus = useRef<string | null>(null);\n  const hoverTimer = useRef<number>(undefined);\n  const typed = useRef({ text: \"\", at: 0 });\n\n  const chosen = useMemo(() => resolveCascaderPath(options, value), [options, value]);\n  const trailOpts = resolveCascaderPath(options, trail);\n  const columns: CascaderOption[][] = [options];\n  for (const o of trailOpts) if (o.children?.length) columns.push(o.children);\n  const shown = Math.min(view, columns.length - 1);\n\n  // Every column is the same height, so revealing a longer one never makes the popup jump.\n  const bodyH = useMemo(() => {\n    let longest = 0;\n    const walk = (l: CascaderOption[]) => {\n      longest = Math.max(longest, l.length);\n      l.forEach((o) => o.children && walk(o.children));\n    };\n    walk(options);\n    return Math.min(MAX_ROWS, longest) * ROW + 8;\n  }, [options]);\n\n  const results = useMemo(() => {\n    const q = fold(query.trim());\n    if (!q) return [];\n    return flattenCascader(options, selectParents)\n      .filter((p) => !p[p.length - 1].disabled && p.some((o) => fold(o.label).includes(q)))\n      .slice(0, 50);\n  }, [options, query, selectParents]);\n  const searching = query.trim().length > 0;\n\n  const optId = (depth: number, index: number) => `${uid}-o${depth}-${index}`;\n  const resultId = (index: number) => `${uid}-r${index}`;\n\n  useLayoutEffect(() => {\n    if (!pendingFocus.current) return;\n    document.getElementById(pendingFocus.current)?.focus();\n    pendingFocus.current = null;\n  });\n  useLayoutEffect(() => () => window.clearTimeout(hoverTimer.current), []);\n\n  const commit = (path: string[], close: boolean) => {\n    setValue(path);\n    onValueChange?.(path, resolveCascaderPath(options, path));\n    if (close) setOpen(false);\n  };\n\n  const openChange = (next: boolean) => {\n    if (next) {\n      // Every open starts from the committed answer, not from wherever browsing was left.\n      const resolved = resolveCascaderPath(options, value);\n      const withKids = resolved.filter((o) => o.children?.length).length;\n      setTrail(value);\n      setView(Math.min(withKids, Math.max(0, resolved.length - 1)));\n      setQuery(\"\");\n      setActive(0);\n      setVia(\"pointer\");\n    }\n    setOpen(next);\n  };\n\n  const expand = (depth: number, o: CascaderOption, how: Via, focusChild: boolean) => {\n    setVia(how);\n    setDir(1);\n    // Re-entering the open parent keeps the deeper browsing intact.\n    if (trail[depth] !== o.value) setTrail([...trail.slice(0, depth), o.value]);\n    setView(depth + 1);\n    if (focusChild) {\n      const kids = o.children ?? [];\n      const keep = trail[depth] === o.value ? kids.findIndex((k) => k.value === trail[depth + 1]) : -1;\n      const first = keep >= 0 ? keep : kids.findIndex((k) => !k.disabled);\n      if (first >= 0) pendingFocus.current = optId(depth + 1, first);\n    }\n  };\n\n  const back = (depth: number, how: Via) => {\n    if (depth === 0) return;\n    setVia(how);\n    setDir(-1);\n    setView(depth - 1);\n    const parent = columns[depth - 1].findIndex((o) => o.value === trail[depth - 1]);\n    pendingFocus.current = optId(depth - 1, Math.max(0, parent));\n  };\n\n  const activate = (depth: number, o: CascaderOption, how: Via, key?: string) => {\n    if (o.disabled) return;\n    const path = [...trail.slice(0, depth), o.value];\n    if (o.children?.length) {\n      // Enter on a selectable parent answers with it; everything else goes deeper.\n      if (selectParents && key === \"Enter\") return commit(path, true);\n      if (selectParents) commit(path, false);\n      expand(depth, o, how, how === \"keyboard\");\n      return;\n    }\n    setTrail(path);\n    commit(path, true);\n  };\n\n  const move = (depth: number, from: number, step: number) => {\n    const list = columns[depth];\n    for (let i = 1; i <= list.length; i++) {\n      const next = step === Infinity ? list.length - i : step === -Infinity ? i - 1 : (from + step * i + list.length * 2) % list.length;\n      if (!list[next].disabled) return document.getElementById(optId(depth, next))?.focus();\n    }\n  };\n\n  const onOptionKey = (e: React.KeyboardEvent, depth: number, index: number) => {\n    const o = columns[depth][index];\n    const stop = () => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    switch (e.key) {\n      case \"ArrowDown\": stop(); return move(depth, index, 1);\n      case \"ArrowUp\": stop(); return move(depth, index, -1);\n      case \"Home\": stop(); return move(depth, index, -Infinity);\n      case \"End\": stop(); return move(depth, index, Infinity);\n      case \"ArrowRight\":\n        stop();\n        if (o.children?.length && !o.disabled) expand(depth, o, \"keyboard\", true);\n        return;\n      case \"ArrowLeft\":\n        stop();\n        if (narrow) return back(depth, \"keyboard\");\n        if (depth > 0) {\n          const parent = columns[depth - 1].findIndex((p) => p.value === trail[depth - 1]);\n          document.getElementById(optId(depth - 1, Math.max(0, parent)))?.focus();\n        }\n        return;\n      case \"Enter\":\n      case \" \":\n        stop();\n        return activate(depth, o, \"keyboard\", e.key);\n    }\n    // Typeahead: letters jump to the next option in this column that starts with them.\n    if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n      const now = e.timeStamp;\n      typed.current = { text: now - typed.current.at > 600 ? e.key : typed.current.text + e.key, at: now };\n      const t = fold(typed.current.text);\n      const list = columns[depth];\n      const start = t.length === 1 ? index + 1 : index;\n      for (let i = 0; i < list.length; i++) {\n        const j = (start + i) % list.length;\n        if (!list[j].disabled && fold(list[j].label).startsWith(t)) {\n          document.getElementById(optId(depth, j))?.focus();\n          break;\n        }\n      }\n    }\n  };\n\n  const onSearchKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Escape\" && query) {\n      // First Escape clears the search, the second closes.\n      e.preventDefault();\n      e.stopPropagation();\n      setQuery(\"\");\n      return;\n    }\n    if (!searching) {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        (initialRef.current ?? document.getElementById(optId(0, 0)))?.focus();\n      }\n      return;\n    }\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (results.length) setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + results.length) % results.length);\n    } else if (e.key === \"Enter\" && results[active]) {\n      e.preventDefault();\n      commit(results[active].map((o) => o.value), true);\n    }\n  };\n\n  // Keyboard moves render on the same frame; pointer moves reveal the column.\n  const mode = via === \"keyboard\" ? \"instant\" : reduce ? \"fade\" : \"full\";\n  const initialIndex = (() => {\n    const depth = narrow ? shown : Math.max(0, Math.min(trail.length, columns.length) - 1);\n    const list = columns[depth] ?? [];\n    const i = list.findIndex((o) => o.value === trail[depth]);\n    return { depth, index: i >= 0 ? i : list.findIndex((o) => !o.disabled) };\n  })();\n\n  const renderColumn = (depth: number) => {\n    const list = columns[depth];\n    const current = list.findIndex((o) => o.value === trail[depth]);\n    const tabStop = current >= 0 ? current : list.findIndex((o) => !o.disabled);\n    const last = value.length - 1;\n    return (\n      <div\n        role=\"listbox\"\n        aria-label={levels?.[depth] ?? `Level ${depth + 1}`}\n        className=\"flex flex-col overflow-y-auto overscroll-contain p-1 outline-none\"\n        style={{ height: bodyH }}\n      >\n        {list.map((o, i) => {\n          const onTrail = trail[depth] === o.value && !!o.children?.length;\n          const selected = value[depth] === o.value && value.slice(0, depth).every((v, k) => v === trail[k]);\n          const endpoint = selected && depth === last;\n          const isInitial = initialIndex.depth === depth && initialIndex.index === i;\n          return (\n            <div\n              key={o.value}\n              id={optId(depth, i)}\n              ref={isInitial ? initialRef : undefined}\n              role=\"option\"\n              aria-selected={selected}\n              aria-disabled={o.disabled || undefined}\n              aria-haspopup={o.children?.length ? \"listbox\" : undefined}\n              aria-expanded={o.children?.length ? onTrail : undefined}\n              tabIndex={i === tabStop ? 0 : -1}\n              data-on-trail={onTrail || undefined}\n              data-selected={selected || undefined}\n              data-disabled={o.disabled || undefined}\n              onClick={() => activate(depth, o, \"pointer\")}\n              onKeyDown={(e) => onOptionKey(e, depth, i)}\n              onPointerEnter={() => {\n                if (expandOn !== \"hover\" || narrow || o.disabled || !o.children?.length || onTrail) return;\n                window.clearTimeout(hoverTimer.current);\n                // A short intent delay so sweeping across the column doesn't open every row.\n                hoverTimer.current = window.setTimeout(() => expand(depth, o, \"pointer\", false), 110);\n              }}\n              onPointerLeave={() => window.clearTimeout(hoverTimer.current)}\n              className={cn(\n                \"group/opt relative flex h-8 shrink-0 cursor-default select-none items-center gap-2 rounded-md pl-2 pr-1.5 text-[13px] text-fg-2 outline-none\",\n                \"transition-colors duration-100 hover:bg-fg/5 hover:text-fg data-[on-trail]:text-fg data-[selected]:text-fg\",\n                \"focus-visible:bg-fg/5 focus-visible:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n                \"data-[disabled]:pointer-events-none data-[disabled]:text-fg-4\",\n              )}\n            >\n              {onTrail && (\n                <motion.span\n                  layoutId={reduce || via === \"keyboard\" ? undefined : `${uid}-trail-${depth}`}\n                  aria-hidden\n                  className=\"absolute inset-0 rounded-md bg-fg/[0.07]\"\n                  transition={spring.snappy}\n                />\n              )}\n              <span className=\"relative min-w-0 flex-1 truncate\">{o.label}</span>\n              {o.hint && <span className=\"relative shrink-0 text-[11.5px] tabular text-fg-3\">{o.hint}</span>}\n              <span className=\"relative grid size-4 shrink-0 place-items-center text-fg-3\">\n                {endpoint ? (\n                  <DrawnCheck reduce={!!reduce} />\n                ) : o.children?.length ? (\n                  <ChevronRight size={14} className=\"transition-transform duration-150 ease-out group-hover/opt:translate-x-px group-data-[on-trail]/opt:text-fg-2\" />\n                ) : null}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n    );\n  };\n\n  const header = (depth: number) =>\n    levels?.[depth] ? (\n      <div className=\"flex h-7 items-end px-3 pb-1 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-4\">{levels[depth]}</div>\n    ) : null;\n\n  const pathKey = value.join(\"\\u0000\");\n\n  return (\n    <Popover.Root open={open} onOpenChange={openChange}>\n      <Popover.Trigger\n        disabled={disabled}\n        data-size={size}\n        data-invalid={invalid || undefined}\n        aria-invalid={invalid || undefined}\n        className={cn(\n          \"group/cascader relative inline-flex w-full min-w-0 select-none items-center gap-2 rounded-lg border border-line-2 bg-raised text-left shadow-[var(--shadow)]\",\n          \"outline-none transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out active:scale-[0.985] active:duration-75\",\n          \"hover:border-fg-4 focus-visible:border-fg-4 focus-visible:ring-3 focus-visible:ring-fg/10 data-[popup-open]:border-fg-4\",\n          \"data-[invalid]:border-danger data-[invalid]:focus-visible:ring-danger/15\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          size === \"sm\" ? \"h-7 pl-2 pr-1.5 text-[12.5px]\" : \"h-8 pl-2.5 pr-2 text-[13px]\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"relative grid min-w-0 flex-1\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={pathKey}\n              className=\"col-start-1 row-start-1 flex min-w-0 items-center gap-1\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n            >\n              {chosen.length === 0 ? (\n                <span className=\"truncate text-fg-4\">{placeholder}</span>\n              ) : (\n                <>\n                  {chosen.length > 1 && (\n                    // Ancestors give way first, so the answer itself stays readable.\n                    <span className=\"flex min-w-0 shrink-[4] items-center gap-1 text-fg-3\">\n                      <span className=\"truncate\">\n                        {chosen.slice(0, -1).map((o, i) => (\n                          <span key={o.value}>\n                            {i > 0 && <span className=\"px-1 text-fg-4\" aria-hidden>/</span>}\n                            {o.label}\n                          </span>\n                        ))}\n                      </span>\n                      <span className=\"text-fg-4\" aria-hidden>/</span>\n                      <span className=\"sr-only\">,</span>\n                    </span>\n                  )}\n                  <span className=\"min-w-[3ch] shrink truncate text-fg\">{chosen[chosen.length - 1].label}</span>\n                </>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <ChevronDown\n          size={size === \"sm\" ? 14 : 16}\n          className=\"shrink-0 text-fg-3 transition-transform duration-200 ease-out group-data-[popup-open]/cascader:rotate-180 motion-reduce:transition-none\"\n        />\n      </Popover.Trigger>\n      {name && <input type=\"hidden\" name={name} value={value.join(\"/\")} />}\n\n      <Popover.Portal>\n        <Popover.Positioner side=\"bottom\" align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            initialFocus={(type) => (searchable ? (type === \"touch\" ? true : inputRef.current) : initialRef.current ?? true)}\n            className={cn(\n              \"flex min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:-translate-y-1 data-starting-style:scale-[0.97] data-starting-style:opacity-0\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            {searchable && (\n              <div className=\"flex h-10 shrink-0 items-center gap-2 border-b border-line px-3\">\n                <Search size={14} className=\"shrink-0 text-fg-4\" />\n                <input\n                  ref={inputRef}\n                  value={query}\n                  onChange={(e) => {\n                    setQuery(e.target.value);\n                    setActive(0);\n                  }}\n                  onKeyDown={onSearchKey}\n                  role=\"combobox\"\n                  aria-expanded={searching}\n                  aria-controls={`${uid}-results`}\n                  aria-activedescendant={searching && results[active] ? resultId(active) : undefined}\n                  aria-autocomplete=\"list\"\n                  aria-label={searchPlaceholder}\n                  placeholder={searchPlaceholder}\n                  autoComplete=\"off\"\n                  spellCheck={false}\n                  enterKeyHint=\"search\"\n                  className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n                />\n              </div>\n            )}\n\n            {searching ? (\n              <div\n                id={`${uid}-results`}\n                role=\"listbox\"\n                aria-label=\"Matches\"\n                className=\"flex w-[min(360px,calc(var(--available-width)-2px))] flex-col overflow-y-auto overscroll-contain p-1\"\n                style={{ maxHeight: bodyH + (levels ? 28 : 0) }}\n              >\n                {results.length === 0 ? (\n                  <div className=\"flex flex-col items-center gap-2 px-4 py-6 text-center\">\n                    <p className=\"text-[12.5px] text-fg-3\">\n                      No matches for <span className=\"text-fg\">“{query.trim()}”</span>\n                    </p>\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        setQuery(\"\");\n                        inputRef.current?.focus();\n                      }}\n                      className=\"h-7 rounded-md border border-line-2 px-2.5 text-[12px] font-medium text-fg outline-none transition-[background-color,scale] duration-150 hover:bg-fg/5 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.97]\"\n                    >\n                      Clear search\n                    </button>\n                  </div>\n                ) : (\n                  results.map((p, i) => {\n                    const leaf = p[p.length - 1];\n                    return (\n                      <div\n                        key={p.map((o) => o.value).join(\"/\")}\n                        id={resultId(i)}\n                        role=\"option\"\n                        aria-selected={i === active}\n                        data-active={i === active || undefined}\n                        onPointerMove={() => i !== active && setActive(i)}\n                        onMouseDown={(e) => e.preventDefault()}\n                        onClick={() => commit(p.map((o) => o.value), true)}\n                        className=\"flex shrink-0 cursor-default select-none flex-col justify-center gap-0.5 rounded-md px-2 py-1.5 data-[active]:bg-fg/[0.07]\"\n                      >\n                        <span className=\"truncate text-[13px] text-fg-2\">\n                          <Highlight text={leaf.label} query={query.trim()} />\n                        </span>\n                        {p.length > 1 && (\n                          <span className=\"truncate text-[11.5px] text-fg-3\">\n                            {p.slice(0, -1).map((o, k) => (\n                              <span key={o.value}>\n                                {k > 0 && \" / \"}\n                                <Highlight text={o.label} query={query.trim()} quiet />\n                              </span>\n                            ))}\n                          </span>\n                        )}\n                      </div>\n                    );\n                  })\n                )}\n              </div>\n            ) : narrow ? (\n              // Stacked: one column at a time, sliding in the direction of travel.\n              <div className=\"w-[min(320px,calc(var(--available-width)-2px))] overflow-hidden\">\n                <div className=\"flex h-9 items-center justify-between gap-2 border-b border-line pl-1 pr-3\">\n                  {shown > 0 ? (\n                    <button\n                      type=\"button\"\n                      onClick={() => back(shown, \"pointer\")}\n                      className=\"flex h-7 min-w-0 items-center gap-1 rounded-md pl-1 pr-2 text-[12.5px] font-medium text-fg outline-none transition-[background-color,scale] duration-150 hover:bg-fg/5 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 active:scale-[0.96]\"\n                    >\n                      <ChevronLeft size={14} className=\"shrink-0 text-fg-3\" />\n                      <span className=\"truncate\">{trailOpts[shown - 1]?.label}</span>\n                    </button>\n                  ) : (\n                    <span className=\"pl-2 text-[12.5px] font-medium text-fg\">{levels?.[0] ?? \"Choose\"}</span>\n                  )}\n                  {shown > 0 && levels?.[shown] && (\n                    <span className=\"shrink-0 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-4\">{levels[shown]}</span>\n                  )}\n                </div>\n                <AnimatePresence initial={false} mode=\"popLayout\" custom={{ dir, mode }}>\n                  <motion.div\n                    key={shown}\n                    custom={{ dir, mode }}\n                    variants={slide}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"leave\"\n                    transition={mode === \"full\" ? spring.soft : { duration: mode === \"fade\" ? 0.15 : 0 }}\n                  >\n                    {renderColumn(shown)}\n                  </motion.div>\n                </AnimatePresence>\n              </div>\n            ) : (\n              <div className=\"flex\">\n                <AnimatePresence initial={false} custom={mode}>\n                  {columns.map((_, depth) => (\n                    <motion.div\n                      key={depth}\n                      custom={mode}\n                      variants={reveal}\n                      initial=\"enter\"\n                      animate=\"shown\"\n                      exit=\"gone\"\n                      transition={mode === \"full\" ? { width: { duration: 0.24, ease: ease.out }, opacity: { duration: 0.16 } } : { duration: mode === \"fade\" ? 0.15 : 0 }}\n                      className={cn(\"shrink-0 overflow-hidden\", depth > 0 && \"border-l border-line\")}\n                    >\n                      <motion.div\n                        key={trail[depth - 1] ?? \"root\"}\n                        style={{ width: COL }}\n                        initial={mode === \"full\" ? { opacity: 0, x: -8 } : false}\n                        animate={{ opacity: 1, x: 0 }}\n                        transition={{ duration: 0.2, ease: ease.out }}\n                      >\n                        {header(depth)}\n                        {renderColumn(depth)}\n                      </motion.div>\n                    </motion.div>\n                  ))}\n                </AnimatePresence>\n              </div>\n            )}\n            <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n              {searching ? (results.length === 1 ? \"1 match\" : `${results.length} matches`) : \"\"}\n            </span>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\ntype Mode = \"instant\" | \"fade\" | \"full\";\nconst reveal = {\n  enter: (m: Mode) => (m === \"instant\" ? { width: COL + 1, opacity: 1 } : m === \"fade\" ? { width: COL + 1, opacity: 0 } : { width: 0, opacity: 0 }),\n  shown: { width: COL + 1, opacity: 1 },\n  gone: (m: Mode) => (m === \"full\" ? { width: 0, opacity: 0, transition: { duration: 0.16, ease: ease.inOut } } : { opacity: 0, transition: { duration: m === \"fade\" ? 0.1 : 0 } }),\n};\nconst slide = {\n  enter: ({ dir, mode }: { dir: number; mode: Mode }) => (mode === \"full\" ? { x: `${dir * 40}%`, opacity: 0 } : { opacity: mode === \"fade\" ? 0 : 1 }),\n  center: { x: \"0%\", opacity: 1 },\n  leave: ({ dir, mode }: { dir: number; mode: Mode }) =>\n    mode === \"full\" ? { x: `${dir * -24}%`, opacity: 0, transition: { duration: 0.16, ease: ease.in } } : { opacity: 0, transition: { duration: mode === \"fade\" ? 0.1 : 0 } },\n};\n\nfunction Highlight({ text, query, quiet }: { text: string; query: string; quiet?: boolean }) {\n  const at = fold(text).indexOf(fold(query));\n  if (!query || at < 0) return <>{text}</>;\n  // Folding strips accents without changing length for these scripts, so indexes line up.\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className={cn(\"bg-transparent\", quiet ? \"text-fg-2\" : \"font-medium text-fg\")}>{text.slice(at, at + query.length)}</mark>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-fg\">\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/cascader"
}
