{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "path-switcher",
  "type": "registry:ui",
  "title": "Path Switcher",
  "description": "Team / project breadcrumb where every step is a searchable switcher that eases.",
  "categories": [
    "app-nav"
  ],
  "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/path-switcher.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { Children, Fragment, isValidElement, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronsUpDown, Plus, Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type PathItem = {\n  id: string;\n  name: string;\n  /** Right-aligned detail in the list: a plan, a count, a framework. */\n  meta?: string;\n  /** A 16px mark shown in the trigger and the list. */\n  icon?: React.ReactNode;\n  disabled?: boolean;\n};\n\ntype Row = PathItem & { create?: true };\nconst CREATE = \"__create__\";\n\n/* -------------------------------------------------------------------------------------------------\n * PathSwitcher: the breadcrumb\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PathSwitcherProps = React.ComponentProps<\"nav\">;\n\n/** A breadcrumb of scopes (team / project / environment) where every step is its own switcher. */\nexport function PathSwitcher({ className, children, \"aria-label\": label = \"Location\", ...rest }: PathSwitcherProps) {\n  const items = Children.toArray(children).filter(isValidElement);\n  return (\n    <nav aria-label={label} className={cn(\"min-w-0\", className)} {...rest}>\n      <ol className=\"flex min-w-0 items-center\">\n        {items.map((child, i) => (\n          <Fragment key={child.key ?? i}>\n            {i > 0 && (\n              <li aria-hidden className=\"shrink-0 px-0.5 text-fg-4\">\n                <svg width=\"16\" height=\"24\" viewBox=\"0 0 16 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.2} strokeLinecap=\"round\">\n                  <path d=\"M10.5 5 5.5 19\" />\n                </svg>\n              </li>\n            )}\n            {/* When space runs out the deepest scope truncates first; its parents give way far less. */}\n            <li className={cn(\"flex min-w-0 items-center\", i < items.length - 1 ? \"shrink-[0.15]\" : \"shrink\")}>{child}</li>\n          </Fragment>\n        ))}\n      </ol>\n    </nav>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * PathSegment: one step, with its own searchable list\n * -----------------------------------------------------------------------------------------------*/\n\nexport type PathSegmentProps = {\n  /** What this step is: \"Team\", \"Project\". Names the switcher and its search. */\n  label: string;\n  items: PathItem[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string, item: PathItem) => void;\n  /** Makes the name a link to this scope; the chevron alone opens the switcher. */\n  href?: string;\n  /** Shown when nothing is chosen. Defaults to \"Select a {label}\". */\n  placeholder?: string;\n  /** The items are on their way: the name becomes a skeleton and the list shows placeholder rows. */\n  loading?: boolean;\n  /** Adds a create row, prefilled with the search. */\n  onCreate?: (name: string) => void;\n  /** Longest the name may grow before it truncates, in pixels. */\n  maxWidth?: number;\n  /** Portal container for the popup. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  className?: string;\n};\n\nexport function PathSegment({\n  label,\n  items,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  href,\n  placeholder,\n  loading = false,\n  onCreate,\n  maxWidth = 180,\n  container,\n  className,\n}: PathSegmentProps) {\n  const reduce = !!useReducedMotion();\n  const [value, setValue] = useControllableState<string | null>({ value: valueProp, defaultValue, onChange: undefined });\n  const current = items.find((i) => i.id === value) ?? null;\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [dir, setDir] = useState(1);\n  const anchor = useRef<HTMLDivElement>(null);\n  const noun = label.toLowerCase();\n\n  const choose = (next: PathItem) => {\n    if (next.id === current?.id) return;\n    setDir(current && items.indexOf(next) < items.indexOf(current) ? -1 : 1);\n    setValue(next.id);\n    onValueChange?.(next.id, next);\n  };\n\n  const q = query.trim().toLowerCase();\n  const matches = useMemo(() => (q ? items.filter((i) => i.name.toLowerCase().includes(q)) : items), [items, q]);\n  const createRow = useMemo<Row>(() => ({ id: CREATE, name: query.trim(), create: true }), [query]);\n  const exact = items.some((i) => i.name.toLowerCase() === q);\n  const rows: Row[] = loading ? [] : onCreate && !exact ? [...matches, createRow] : matches;\n  const { setScroller, y, height, opacity } = useGlide(reduce);\n\n  const text = current?.name ?? placeholder ?? `Select a ${noun}`;\n  const name = (\n    <Label text={text} icon={current?.icon} id={current?.id ?? \"none\"} dir={dir} loading={loading} empty={!current} maxWidth={maxWidth} reduce={reduce} />\n  );\n\n  const segmentClass = cn(\n    \"group/seg relative flex h-8 min-w-0 select-none items-center gap-2 rounded-lg px-2 text-[13px] outline-none\",\n    \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n    \"transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.98] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n  );\n\n  return (\n    <Combobox.Root<Row>\n      items={onCreate ? [...items, createRow] : items}\n      filteredItems={rows}\n      value={current}\n      onValueChange={(next) => {\n        if (!next) return;\n        if (next.create) return onCreate?.(query.trim());\n        choose(next);\n      }}\n      inputValue={query}\n      onInputValueChange={setQuery}\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) setQuery(\"\");\n      }}\n      autoHighlight\n      isItemEqualToValue={(a, b) => a.id === b.id}\n      itemToStringLabel={(i) => i.name}\n      itemToStringValue={(i) => i.id}\n    >\n      <div ref={anchor} data-open={open ? \"\" : undefined} className={cn(\"flex min-w-0 items-center rounded-lg\", className)}>\n        {href ? (\n          <>\n            <a href={href} aria-current={current ? \"page\" : undefined} className={cn(segmentClass, \"cursor-pointer pr-1.5\")}>\n              {name}\n            </a>\n            <Combobox.Trigger\n              aria-label={`Switch ${noun}`}\n              className={cn(\n                \"relative grid h-8 w-5 shrink-0 place-items-center rounded-md text-fg-4 outline-none transition-[background-color,color,scale] duration-150\",\n                \"hover:bg-hover hover:text-fg-2 data-popup-open:bg-hover data-popup-open:text-fg-2 active:scale-[0.92]\",\n                \"before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n              )}\n            >\n              <ChevronsUpDown size={13} />\n            </Combobox.Trigger>\n          </>\n        ) : (\n          <Combobox.Trigger aria-label={`${label}: ${current?.name ?? \"none\"}. Switch ${noun}`} className={cn(segmentClass, \"pr-1.5 data-popup-open:bg-hover\")}>\n            {name}\n            <ChevronsUpDown size={13} className=\"shrink-0 text-fg-4 transition-colors group-hover/seg:text-fg-3 group-data-popup-open/seg:text-fg-2\" />\n          </Combobox.Trigger>\n        )}\n      </div>\n\n      <Combobox.Portal container={container}>\n        <Combobox.Positioner anchor={anchor} align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Combobox.Popup\n            aria-label={`Switch ${noun}`}\n            aria-busy={loading || undefined}\n            className={cn(\n              \"w-[max(var(--anchor-width),16rem)] max-w-(--available-width) origin-(--transform-origin) 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 data-ending-style:duration-120 data-ending-style:ease-out\",\n              \"data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0\",\n              \"motion-reduce:scale-100 motion-reduce:translate-none\",\n            )}\n          >\n            <label className=\"flex h-10 items-center gap-2 border-b border-line px-3 text-fg-3\">\n              <Search size={14} className=\"shrink-0\" />\n              <Combobox.Input\n                placeholder={`Find a ${noun}`}\n                aria-label={`Find a ${noun}`}\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            </label>\n            <div ref={setScroller} className=\"relative max-h-[min(var(--available-height),17rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1\">\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-fg/[0.06]\" />\n              {loading ? (\n                <div role=\"status\" aria-label={`Loading ${noun}s`} className=\"flex flex-col\">\n                  {[70, 52, 62].map((w) => (\n                    <div key={w} className=\"flex h-8 items-center gap-2.5 px-2\">\n                      <span className=\"size-4 rounded bg-hover\" />\n                      <span className=\"h-2 rounded-full bg-hover motion-safe:animate-pulse-soft\" style={{ width: `${w}%` }} />\n                    </div>\n                  ))}\n                </div>\n              ) : (\n                <>\n                  {matches.length > 0 && (\n                    <div className=\"flex h-7 items-center justify-between px-2 font-mono text-2xs uppercase tracking-[0.08em] text-fg-4\">\n                      <span>{label}s</span>\n                      <span className=\"tabular\">{matches.length}</span>\n                    </div>\n                  )}\n                  {!matches.length && (\n                    <p className=\"truncate px-2 py-3 text-[12.5px] text-fg-3\">\n                      {q ? (\n                        <>\n                          No {noun} matches <span className=\"text-fg-2\">“{query.trim()}”</span>\n                        </>\n                      ) : (\n                        `No ${noun}s yet`\n                      )}\n                    </p>\n                  )}\n                </>\n              )}\n              <Combobox.List className=\"outline-none\">\n                {(i: Row) =>\n                  i.create ? (\n                    <Combobox.Item\n                      key={CREATE}\n                      value={i}\n                      className={cn(itemClass, matches.length > 0 && \"mt-1 before:absolute before:inset-x-1 before:-top-[3px] before:h-px before:bg-line\")}\n                    >\n                      <span className=\"grid size-4 shrink-0 place-items-center rounded-[4px] border border-dashed border-line-2 text-fg-3 transition-colors group-data-highlighted/item:border-fg-4 group-data-highlighted/item:text-fg\">\n                        <Plus size={11} />\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate text-fg-2 group-data-highlighted/item:text-fg\">\n                        {i.name ? (\n                          <>\n                            Create {noun} <span className=\"text-fg\">“{i.name}”</span>\n                          </>\n                        ) : (\n                          `Create ${noun}`\n                        )}\n                      </span>\n                    </Combobox.Item>\n                  ) : (\n                    <Combobox.Item key={i.id} value={i} disabled={i.disabled} className={itemClass}>\n                      {i.icon && <span className=\"grid size-4 shrink-0 place-items-center text-fg-3 [&_svg]:size-4\">{i.icon}</span>}\n                      <span className=\"min-w-0 flex-1 truncate\">{i.name}</span>\n                      {i.meta && <span className=\"shrink-0 text-[11.5px] text-fg-4 tabular\">{i.meta}</span>}\n                      <span className=\"grid size-4 shrink-0 place-items-center\">{i.id === current?.id && <DrawnCheck reduce={reduce} />}</span>\n                    </Combobox.Item>\n                  )\n                }\n              </Combobox.List>\n            </div>\n          </Combobox.Popup>\n        </Combobox.Positioner>\n      </Combobox.Portal>\n    </Combobox.Root>\n  );\n}\n\nconst itemClass = cn(\n  \"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10\",\n  \"data-disabled:text-fg-4 data-disabled:[&_*]:text-fg-4\",\n);\n\n/* -------------------------------------------------------------------------------------------------\n * The name: rolls to the new value and eases its width, so the rest of the path slides over\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Label({\n  text,\n  icon,\n  id,\n  dir,\n  loading,\n  empty,\n  maxWidth,\n  reduce,\n}: {\n  text: string;\n  icon?: React.ReactNode;\n  id: string;\n  dir: number;\n  loading: boolean;\n  empty: boolean;\n  maxWidth: number;\n  reduce: boolean;\n}) {\n  const [measure, setMeasure] = useState<HTMLSpanElement | null>(null);\n  const [width, setWidth] = useState<number | null>(null);\n  useEffect(() => {\n    if (!measure) return;\n    const ro = new ResizeObserver(() => setWidth(Math.min(Math.ceil(measure.getBoundingClientRect().width), maxWidth)));\n    ro.observe(measure);\n    return () => ro.disconnect();\n  }, [measure, maxWidth]);\n\n  const key = loading ? \"loading\" : id;\n  const target = loading ? 72 : width;\n  // Variants read the direction through `custom`, so the leaving name uses the new direction too.\n  const roll = {\n    variants: {\n      enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * 8, filter: \"blur(2px)\" }),\n      center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n      exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * -8, filter: \"blur(2px)\" }),\n    },\n    custom: dir,\n    initial: \"enter\",\n    animate: \"center\",\n    exit: \"exit\",\n  };\n\n  return (\n    <>\n      {icon !== undefined && !loading && (\n        <span className=\"grid size-4 shrink-0 place-items-center overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n            <motion.span key={id} {...roll} transition={reduce ? { duration: 0.12 } : { duration: 0.26, ease: ease.out }} className=\"grid size-4 place-items-center [&_svg]:size-4\">\n              {icon}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      )}\n      <motion.span\n        initial={false}\n        animate={target === null ? undefined : { width: target }}\n        transition={reduce ? { duration: 0 } : spring.snappy}\n        className=\"relative grid h-5 min-w-0 items-center overflow-hidden\"\n      >\n        {/* Measures the full name at its natural width; the visible span animates to it. */}\n        <span ref={setMeasure} aria-hidden className=\"invisible absolute left-0 top-0 whitespace-nowrap font-medium\">\n          {text}\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n          <motion.span\n            key={key}\n            {...roll}\n            transition={reduce ? { duration: 0.12 } : { duration: 0.26, ease: ease.out }}\n            className={cn(\"col-start-1 row-start-1 truncate whitespace-nowrap font-medium\", empty ? \"text-fg-3\" : \"text-fg\")}\n          >\n            {loading ? <span className=\"block h-2.5 w-[72px] rounded-full bg-hover motion-safe:animate-pulse-soft\" /> : text}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n      {loading && <span className=\"sr-only\">Loading</span>}\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 d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.28, ease: ease.out, delay: 0.06 }} />\n    </svg>\n  );\n}\n\n// One highlight for the list: glides after the pointer, jumps for the keyboard and for filtering.\nfunction useGlide(reduce: boolean) {\n  const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!scroller) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = scroller.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      let top = 0;\n      for (let n: HTMLElement | null = el; n && n !== scroller; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n      }\n      opacity.jump(el.hasAttribute(\"data-disabled\") ? 0.45 : 1);\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(scroller, { subtree: true, childList: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    scroller.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      scroller.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [scroller, reduce, y, height, opacity]);\n\n  return { setScroller, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/path-switcher"
}
