{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workspace-switcher",
  "type": "registry:ui",
  "title": "Workspace Switcher",
  "description": "Searchable workspace menu with number shortcuts, pending switches and a rolling trigger.",
  "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/workspace-switcher.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronsUpDown, Loader, Plus, Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Workspace = {\n  id: string;\n  name: string;\n  /** Plan or role, shown under the name in the trigger and beside it in the list: \"Pro\", \"Free trial\". */\n  plan?: string;\n  /** Extra detail for the trigger's second line: \"24 members\". */\n  detail?: string;\n  /** An image URL or your own mark. Without one, the first letter sits on a neutral tile. */\n  logo?: string | React.ReactNode;\n  disabled?: boolean;\n};\n\ntype Row = Workspace & { create?: true };\nconst CREATE = \"__create__\";\n\nexport type WorkspaceSwitcherProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"onChange\"> & {\n  workspaces: Workspace[];\n  /** The current workspace id. Pair with onValueChange to control it. */\n  value?: string;\n  defaultValue?: string;\n  /**\n   * Called with the chosen id. Return a promise to show the switch as pending: the trigger\n   * spins until it settles, and a rejection puts the previous workspace back.\n   */\n  onValueChange?: (id: string, workspace: Workspace) => void | Promise<unknown>;\n  /** Adds a create row. Receives whatever was typed in the search, or \"\" from the plain row. */\n  onCreate?: (name: string) => void;\n  /**\n   * The modifier for the 1–9 shortcuts, or false for none. Browsers keep ⌘1–9 for their own tabs,\n   * so a web app usually wants \"alt\" or \"ctrl\"; \"mod\" (⌘ on a Mac, Ctrl elsewhere) suits desktop shells.\n   */\n  shortcut?: \"mod\" | \"alt\" | \"ctrl\" | false;\n  /** One line, no plan: for a top bar rather than a sidebar. */\n  compact?: boolean;\n  /** Portal container for the popup. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n};\n\n/**\n * The workspace in the corner of a sidebar: its mark, name and plan, and one press away\n * from every other workspace you belong to, searchable, with number shortcuts.\n */\nexport function WorkspaceSwitcher({\n  workspaces,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onCreate,\n  shortcut = \"mod\",\n  compact = false,\n  container,\n  disabled,\n  className,\n  ...rest\n}: WorkspaceSwitcherProps) {\n  const reduce = !!useReducedMotion();\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? workspaces[0]?.id ?? \"\" });\n  const current = workspaces.find((w) => w.id === value) ?? workspaces[0];\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [pending, setPending] = useState<string | null>(null);\n  const [failed, setFailed] = useState<string | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  // +1 when the new workspace sits below the old one in the list, -1 above: the trigger rolls that way.\n  const [dir, setDir] = useState(1);\n  const mac = useIsMac();\n  const failTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(failTimer.current), []);\n\n  const choose = async (next: Workspace) => {\n    if (!current || pending || next.id === current.id || next.disabled) return;\n    const prev = current;\n    setDir(workspaces.indexOf(next) >= workspaces.indexOf(prev) ? 1 : -1);\n    setFailed(null);\n    window.clearTimeout(failTimer.current);\n    setValue(next.id);\n    const result = onValueChange?.(next.id, next);\n    if (!(result instanceof Promise)) return setAnnounce(`Switched to ${next.name}`);\n    setPending(next.id);\n    try {\n      await result;\n      setAnnounce(`Switched to ${next.name}`);\n    } catch {\n      setDir(-1 * (workspaces.indexOf(next) >= workspaces.indexOf(prev) ? 1 : -1));\n      setValue(prev.id);\n      setFailed(next.name);\n      setAnnounce(`Couldn’t switch to ${next.name}. Still in ${prev.name}.`);\n      failTimer.current = window.setTimeout(() => setFailed(null), 4000);\n    } finally {\n      setPending(null);\n    }\n  };\n\n  // Number shortcuts work from anywhere, except while typing in someone else's field.\n  const chooseRef = useRef(choose);\n  useEffect(() => {\n    chooseRef.current = choose;\n  });\n  useEffect(() => {\n    if (!shortcut || disabled) return;\n    const onKey = (e: KeyboardEvent) => {\n      const want = shortcut === \"alt\" ? e.altKey : shortcut === \"ctrl\" ? e.ctrlKey : mac ? e.metaKey : e.ctrlKey;\n      const others = (shortcut !== \"alt\" && e.altKey) || (shortcut === \"alt\" && (e.metaKey || e.ctrlKey)) || e.shiftKey;\n      const digit = /^Digit([1-9])$/.exec(e.code)?.[1];\n      if (!want || others || !digit) return;\n      const t = e.target as HTMLElement | null;\n      const typing = t?.closest(\"input, textarea, [contenteditable=true]\") && !t.closest(\"[data-workspace-switcher]\");\n      if (typing) return;\n      const target = workspaces[Number(digit) - 1];\n      if (!target) return;\n      e.preventDefault();\n      setOpen(false);\n      void chooseRef.current(target);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, disabled, mac, workspaces]);\n\n  const q = query.trim().toLowerCase();\n  const matches = useMemo(() => (q ? workspaces.filter((w) => w.name.toLowerCase().includes(q)) : workspaces), [workspaces, q]);\n  const createRow = useMemo<Row>(() => ({ id: CREATE, name: query.trim(), create: true }), [query]);\n  const exact = workspaces.some((w) => w.name.toLowerCase() === q);\n  const rows: Row[] = onCreate && !exact ? [...matches, createRow] : matches;\n  const keyLabel = shortcut === \"alt\" ? (mac ? \"⌥\" : \"Alt+\") : shortcut === \"ctrl\" ? (mac ? \"⌃\" : \"Ctrl+\") : mac ? \"⌘\" : \"Ctrl+\";\n  const { setScroller, y, height, opacity } = useGlide(reduce);\n\n  if (!current) return null;\n  const busy = pending !== null;\n  // While a switch is pending the trigger already shows where you're going, controlled or not.\n  const shown = (pending && workspaces.find((w) => w.id === pending)) || current;\n  const second = failed ? \"Couldn’t switch. Try again\" : [shown.plan, shown.detail].filter(Boolean).join(\" · \");\n\n  return (\n    <Combobox.Root<Row>\n      items={onCreate ? [...workspaces, createRow] : workspaces}\n      filteredItems={rows}\n      value={shown}\n      onValueChange={(next) => {\n        if (!next) return;\n        if (next.create) return onCreate?.(query.trim());\n        void 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      disabled={disabled}\n      isItemEqualToValue={(a, b) => a.id === b.id}\n      itemToStringLabel={(w) => w.name}\n      itemToStringValue={(w) => w.id}\n    >\n      <Combobox.Trigger\n        data-workspace-switcher=\"\"\n        aria-label={`Workspace: ${shown.name}. Switch workspace`}\n        aria-busy={busy || undefined}\n        className={cn(\n          \"group/ws relative flex w-full min-w-0 select-none items-center gap-2.5 rounded-lg px-1.5 text-left outline-none\",\n          \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          compact ? \"h-8 gap-2\" : \"h-11\",\n          \"transition-[background-color,scale] duration-150 ease-out hover:bg-hover data-popup-open:bg-hover active:scale-[0.98] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className={cn(\"relative grid shrink-0\", compact ? \"size-5\" : \"size-7\")}>\n          <AnimatePresence initial={false} custom={dir}>\n            <motion.span\n              key={shown.id}\n              custom={dir}\n              variants={roll(reduce, compact ? 8 : 12)}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1\"\n            >\n              <Logo workspace={shown} size={compact ? 20 : 28} />\n            </motion.span>\n          </AnimatePresence>\n          <AnimatePresence>\n            {busy && (\n              <motion.span\n                key=\"busy\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.12 }}\n                className={cn(\"absolute inset-0 grid place-items-center bg-raised/80 text-fg\", compact ? \"rounded-[5px]\" : \"rounded-md\")}\n              >\n                <Loader size={compact ? 12 : 14} className=\"animate-spin motion-reduce:animate-[spin_2s_linear_infinite]\" />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n\n        <span className=\"grid min-w-0 flex-1 overflow-hidden\">\n          <AnimatePresence initial={false} custom={dir}>\n            <motion.span\n              key={shown.id}\n              custom={dir}\n              variants={roll(reduce, compact ? 8 : 14)}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1 flex min-w-0 flex-col justify-center\"\n            >\n              <span className=\"truncate text-[13px] font-medium leading-[18px] tracking-[-0.01em] text-fg\">{shown.name}</span>\n              {!compact && second && (\n                <span title={failed ? `Couldn’t switch to ${failed}` : undefined} className={cn(\"truncate text-[11.5px] leading-[15px] transition-colors\", failed ? \"text-danger\" : \"text-fg-3\")}>\n                  {second}\n                </span>\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n        <ChevronsUpDown size={14} className=\"shrink-0 text-fg-4 transition-colors group-hover/ws:text-fg-3 group-data-popup-open/ws:text-fg-2\" />\n      </Combobox.Trigger>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{announce}</span>\n\n      <Combobox.Portal container={container}>\n        <Combobox.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Combobox.Popup\n            aria-label=\"Switch workspace\"\n            data-workspace-switcher=\"\"\n            className={cn(\n              \"w-[max(var(--anchor-width),17rem)] 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              \"data-[side=top]:data-starting-style:translate-y-1\",\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 workspace\"\n                aria-label=\"Find a workspace\"\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\n            <div ref={setScroller} className=\"relative max-h-[min(var(--available-height),18rem)] 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              {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>Workspaces</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                  No workspace matches <span className=\"text-fg-2\">“{query.trim()}”</span>\n                </p>\n              )}\n              <Combobox.List className=\"outline-none\">\n                {(w: Row) =>\n                  w.create ? (\n                    <Combobox.Item\n                      key={CREATE}\n                      value={w}\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-5 shrink-0 place-items-center rounded-[5px] 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={12} />\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate text-fg-2 group-data-highlighted/item:text-fg\">\n                        {w.name ? <>Create <span className=\"text-fg\">“{w.name}”</span></> : \"Create workspace\"}\n                      </span>\n                    </Combobox.Item>\n                  ) : (\n                    <Combobox.Item key={w.id} value={w} disabled={w.disabled} className={itemClass}>\n                      <Logo workspace={w} size={20} />\n                      <span className=\"min-w-0 flex-1 truncate\">{w.name}</span>\n                      {w.plan && <span className=\"shrink-0 text-[11.5px] text-fg-4\">{w.plan}</span>}\n                      <span className=\"grid h-5 min-w-7 shrink-0 place-items-center\">\n                        {w.id === shown.id ? (\n                          busy ? (\n                            <Loader size={13} className=\"animate-spin text-fg-2\" />\n                          ) : (\n                            <DrawnCheck reduce={reduce} />\n                          )\n                        ) : (\n                          shortcut &&\n                          workspaces.indexOf(w) < 9 && (\n                            <kbd className=\"font-mono text-2xs text-fg-4 tabular pointer-coarse:hidden\">\n                              {keyLabel}\n                              {workspaces.indexOf(w) + 1}\n                            </kbd>\n                          )\n                        )}\n                      </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// The trigger rolls like a drum: the old workspace leaves the way the list runs, the new one arrives from the other side.\nconst roll = (reduce: boolean, distance: number) => ({\n  enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * distance, filter: \"blur(2px)\" }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: reduce ? { duration: 0.15 } : { duration: 0.32, ease: ease.out } },\n  exit: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: d * -distance, filter: \"blur(2px)\", transition: { duration: 0.2, ease: ease.out } }),\n});\n\n/** The workspace's mark: its image, your node, or its first letter on a neutral tile. */\nexport function WorkspaceLogo({ workspace, size = 20, className }: { workspace: Workspace; size?: number; className?: string }) {\n  return <Logo workspace={workspace} size={size} className={className} />;\n}\n\nfunction Logo({ workspace, size, className }: { workspace: Workspace; size: number; className?: string }) {\n  const radius = size >= 28 ? \"rounded-md\" : \"rounded-[5px]\";\n  const box = cn(\"grid shrink-0 place-items-center overflow-hidden\", radius, className);\n  const style = { width: size, height: size };\n  if (typeof workspace.logo === \"string\")\n    // eslint-disable-next-line @next/next/no-img-element -- a plain image keeps the component portable\n    return <img src={workspace.logo} alt=\"\" width={size} height={size} style={style} className={cn(box, \"object-cover\")} />;\n  if (workspace.logo)\n    return (\n      <span aria-hidden style={style} className={cn(box, \"bg-fg text-frame [&_svg]:size-[62%]\")}>\n        {workspace.logo}\n      </span>\n    );\n  return (\n    <span aria-hidden style={{ ...style, fontSize: Math.round(size * 0.46) }} className={cn(box, \"border border-line-2 bg-hover font-semibold leading-none text-fg-2\")}>\n      {workspace.name.trim().charAt(0).toUpperCase()}\n    </span>\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.06 }}\n      />\n    </svg>\n  );\n}\n\nconst noop = () => () => {};\nfunction useIsMac() {\n  return useSyncExternalStore(\n    noop,\n    () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent),\n    () => true,\n  );\n}\n\n// One highlight for the list: it glides after the pointer and jumps for arrow keys and\n// filtering, so keyboard use never waits on an animation.\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/workspace-switcher"
}
