{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workspace-switcher",
  "title": "Workspace Switcher",
  "description": "A workspace / organization switcher: active workspace in the trigger, grouped searchable menu in a portalled panel, pinned create and settings footer.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/workspace-switcher.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { Check, ChevronsUpDown, Plus, Search, Settings } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Entrance keyframes ship with the component: React 19 hoists <style href> into\n * <head> and dedupes by href, so N switchers on a page still emit one rule set.\n * One keyframe per side — the panel always slides *away* from its trigger.\n */\nconst KEYFRAMES = `@keyframes zg-workspace-in-bottom{from{opacity:0;transform:translateY(-4px) scale(0.98)}to{opacity:1;transform:none}}\n@keyframes zg-workspace-in-top{from{opacity:0;transform:translateY(4px) scale(0.98)}to{opacity:1;transform:none}}`\n\n/**\n * The lock count and the original style snapshot live in data attributes on\n * `document.body`, not in module-level variables. This component can be installed on its\n * own next to other overlays, each shipping its own copy of the same lock. Module-level\n * counters would each count alone: with two overlays nested, the one released second\n * restores over the first (the first writes back \"\", the second writes back the \"hidden\"\n * it recorded) and the page stays locked forever. Data attributes are globally unique, so\n * independent copies cooperate correctly.\n */\nconst LOCK_COUNT = \"zyScrollLocks\"\nconst LOCK_OVERFLOW = \"zyScrollLockOverflow\"\nconst LOCK_PADDING = \"zyScrollLockPadding\"\n\nfunction lockScroll() {\n  const body = document.body\n  const count = Number(body.dataset[LOCK_COUNT] ?? \"0\")\n  if (count === 0) {\n    // store whatever inline style is there now rather than assuming it was empty: something else may have set it.\n    body.dataset[LOCK_OVERFLOW] = body.style.overflow\n    body.dataset[LOCK_PADDING] = body.style.paddingRight\n    // locking usually removes the vertical scrollbar, widening the viewport and jumping\n    // the content ~15px to the right, so that width has to go back into paddingRight. But\n    // it must **not** be guessed with `innerWidth - clientWidth`: with\n    // `scrollbar-gutter: stable` the gutter is permanent, so overflow:hidden changes\n    // nothing and adding 15px would shove the content left instead. Setting overflow\n    // first and measuring the actual difference is right on both kinds of page, and the\n    // component never has to know which strategy the host uses.\n    const widthBefore = document.documentElement.clientWidth\n    body.style.overflow = \"hidden\"\n    const shift = document.documentElement.clientWidth - widthBefore\n    if (shift > 0) {\n      const current = Number.parseFloat(window.getComputedStyle(body).paddingRight || \"0\")\n      body.style.paddingRight = `${current + shift}px`\n    }\n  }\n  body.dataset[LOCK_COUNT] = String(count + 1)\n}\n\nfunction releaseScroll() {\n  const body = document.body\n  const next = Math.max(0, Number(body.dataset[LOCK_COUNT] ?? \"0\") - 1)\n  if (next > 0) {\n    body.dataset[LOCK_COUNT] = String(next)\n    return\n  }\n  body.style.overflow = body.dataset[LOCK_OVERFLOW] ?? \"\"\n  body.style.paddingRight = body.dataset[LOCK_PADDING] ?? \"\"\n  delete body.dataset[LOCK_COUNT]\n  delete body.dataset[LOCK_OVERFLOW]\n  delete body.dataset[LOCK_PADDING]\n}\n\nconst FOCUSABLE_SELECTOR =\n  'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction focusablesIn(root: HTMLElement): HTMLElement[] {\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n    el => el.offsetParent !== null || el === document.activeElement,\n  )\n}\n\n/** Gap between the trigger and the panel. */\nconst GAP = 6\n/** Breathing room kept between the panel and the boundary it is clamped into. */\nconst EDGE_MARGIN = 8\n/** The panel never shrinks below these; it scrolls its row list instead. */\nconst MIN_PANEL_HEIGHT = 176\nconst MIN_PANEL_WIDTH = 200\n\nexport interface Workspace {\n  /** Stable, unique across every group — it is the value handed to `onValueChange`. */\n  id: string\n  name: string\n  /** Square logo. Falls back to initials when absent or when the request fails. */\n  logoUrl?: string\n  /** Short plan pill rendered in the trigger and the row (\"Pro\", \"Free\", \"Enterprise\"). */\n  plan?: string\n  /** Extra search terms (slug, org id, aliases). Matched, never rendered. */\n  keywords?: string[]\n  /** Dimmed, skipped by the arrow keys, not selectable (no seat, suspended billing). */\n  disabled?: boolean\n}\n\nexport interface WorkspaceGroup {\n  /** Section heading — \"Personal\", \"Teams\", \"Agencies\". */\n  label: string\n  workspaces: Workspace[]\n}\n\nexport interface WorkspaceSwitcherProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    \"type\" | \"children\" | \"value\" | \"onSelect\" | \"onChange\"\n  > {\n  /** Sections rendered in order. Empty groups are dropped while filtering. */\n  groups: WorkspaceGroup[]\n  /** id of the active workspace. Controlled — the component owns no selection state. */\n  value: string\n  onValueChange: (id: string) => void\n  /** Controlled open state; omit it to let the component own it. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Edge the panel lines up with before it is shift-clamped. Default \"start\". */\n  align?: \"start\" | \"end\"\n  /**\n   * The search box appears only once the total workspace count is *greater* than\n   * this. Default 5; clamped to >= 0, so 0 means \"always search\".\n   */\n  searchThreshold?: number\n  searchPlaceholder?: string\n  emptyText?: string\n  /** Shown instead of a workspace when `value` matches nothing. */\n  placeholder?: string\n  /** Pinned footer action. The row is omitted when the callback is. */\n  onCreate?: () => void\n  createLabel?: string\n  /** Pinned footer action. The row is omitted when the callback is. */\n  onSettings?: () => void\n  settingsLabel?: string\n  /** Merged onto the portalled panel; `className` goes to the trigger button. */\n  panelClassName?: string\n}\n\ntype PanelSide = \"top\" | \"bottom\"\n\ninterface Bounds {\n  top: number\n  right: number\n  bottom: number\n  left: number\n}\n\ninterface PanelLayout {\n  side: PanelSide\n  left: number\n  top: number\n  maxWidth: number\n  maxHeight: number\n}\n\nconst ENTER_ANIMATION: Record<PanelSide, string> = {\n  bottom: \"[animation:zg-workspace-in-bottom_140ms_ease-out]\",\n  top: \"[animation:zg-workspace-in-top_140ms_ease-out]\",\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), Math.max(min, max))\n}\n\n/** First grapheme of a word, so an emoji or a CJK name is never cut in half. */\nfunction firstChar(word: string) {\n  return Array.from(word)[0] ?? \"\"\n}\n\n/** \"Acme\" → \"A\"; \"Northwind Labs\" → \"NL\"; \"\" → \"?\". Deterministic, render-safe. */\nfunction initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  if (words.length === 1) return firstChar(words[0]).toUpperCase()\n  return (firstChar(words[0]) + firstChar(words[words.length - 1])).toUpperCase()\n}\n\nfunction matchesQuery(workspace: Workspace, query: string) {\n  const haystack = [workspace.name, workspace.plan ?? \"\", ...(workspace.keywords ?? [])].join(\" \").toLowerCase()\n  return haystack.includes(query)\n}\n\n/**\n * The box the panel has to stay inside, in viewport coordinates.\n *\n * Two ancestor kinds matter and they are treated differently — this is the whole\n * reason the panel is portalled to <body>:\n *\n * - `overflow: hidden | clip` ancestors (a rounded sidebar, a docs preview stage,\n *   a card) would *clip* an in-flow panel: the rows exist in the DOM, the pixels\n *   don't, and nothing is clickable. A portalled panel is not their descendant so\n *   they cannot clip it — and for the same reason they must NOT act as a boundary\n *   either, or a 14-row menu gets crushed into a 120px decorative card.\n * - `overflow: auto | scroll` ancestors are a real viewport for the trigger (a\n *   scrollable sidebar, a dialog body). A panel spilling out of one would float\n *   over unrelated content while its trigger scrolls away, so those DO clamp it.\n *\n * The window is always the outermost boundary.\n */\nfunction clipBounds(anchor: HTMLElement): Bounds {\n  const bounds: Bounds = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight }\n\n  let node: HTMLElement | null = anchor.parentElement\n  while (node && node !== document.body && node !== document.documentElement) {\n    const style = getComputedStyle(node)\n    if (/auto|scroll/.test(`${style.overflowX} ${style.overflowY}`)) {\n      const rect = node.getBoundingClientRect()\n      bounds.top = Math.max(bounds.top, rect.top)\n      bounds.left = Math.max(bounds.left, rect.left)\n      bounds.right = Math.min(bounds.right, rect.right)\n      bounds.bottom = Math.min(bounds.bottom, rect.bottom)\n    }\n    node = node.parentElement\n  }\n\n  return bounds\n}\n\n/**\n * One synchronous pass: measure natural size → flip → cap size → align → shift.\n *\n * The natural size is read with our own caps momentarily lifted. Measuring a\n * panel that is already clamped by a previous `maxHeight` makes it look like it\n * always fits, which is the classic hand-rolled-popover oscillation. Nothing is\n * painted in between (this runs inside one ResizeObserver / rAF callback), but\n * dropping the cap does collapse the scroll area, so the row list's scroll\n * offset is saved and put back.\n */\nfunction computeLayout(\n  anchor: HTMLElement,\n  panel: HTMLElement,\n  scroller: HTMLElement | null,\n  align: \"start\" | \"end\",\n): PanelLayout {\n  const bounds = clipBounds(anchor)\n  const area = {\n    top: bounds.top + EDGE_MARGIN,\n    left: bounds.left + EDGE_MARGIN,\n    right: bounds.right - EDGE_MARGIN,\n    bottom: bounds.bottom - EDGE_MARGIN,\n  }\n  const rect = anchor.getBoundingClientRect()\n\n  const previousMaxWidth = panel.style.maxWidth\n  const previousMaxHeight = panel.style.maxHeight\n  const scrollTop = scroller ? scroller.scrollTop : 0\n  panel.style.maxWidth = \"none\"\n  panel.style.maxHeight = \"none\"\n  const naturalWidth = panel.offsetWidth\n  const naturalHeight = panel.offsetHeight\n  panel.style.maxWidth = previousMaxWidth\n  panel.style.maxHeight = previousMaxHeight\n  if (scroller) scroller.scrollTop = scrollTop\n\n  const spaceBelow = area.bottom - rect.bottom - GAP\n  const spaceAbove = rect.top - area.top - GAP\n\n  // FLIP only when the other side is genuinely roomier, so a panel too tall for\n  // both sides stays below the trigger and scrolls instead of ping-ponging.\n  const side: PanelSide = naturalHeight > spaceBelow && spaceAbove > spaceBelow ? \"top\" : \"bottom\"\n\n  const maxHeight = Math.max(MIN_PANEL_HEIGHT, Math.floor(side === \"bottom\" ? spaceBelow : spaceAbove))\n  const maxWidth = Math.max(MIN_PANEL_WIDTH, Math.floor(area.right - area.left))\n  const width = Math.min(naturalWidth, maxWidth)\n  const height = Math.min(naturalHeight, maxHeight)\n\n  const anchored = align === \"end\" ? rect.right - width : rect.left\n  // SHIFT along the cross axis until the panel sits inside the boundary.\n  const left = clamp(anchored, area.left, area.right - width)\n  const top = side === \"bottom\" ? rect.bottom + GAP : rect.top - GAP - height\n\n  return { side, left: Math.round(left), top: Math.round(top), maxWidth, maxHeight }\n}\n\nfunction sameLayout(a: PanelLayout | null, b: PanelLayout): a is PanelLayout {\n  return (\n    a !== null &&\n    a.side === b.side &&\n    a.left === b.left &&\n    a.top === b.top &&\n    a.maxWidth === b.maxWidth &&\n    a.maxHeight === b.maxHeight\n  )\n}\n\nfunction subscribeNoop() {\n  // \"am I on the client\" has no external event to subscribe to.\n  return () => {}\n}\n\n/** SSR-safe portal gate: false on the server and on the hydrating frame. */\nfunction useIsClient() {\n  return React.useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  )\n}\n\n/** Logo with an initials fallback. Comparing against the failed URL resets it when `logoUrl` changes. */\nfunction WorkspaceAvatar({ className, workspace }: { className?: string; workspace: Workspace }) {\n  const [failedSrc, setFailedSrc] = React.useState<string | null>(null)\n  const src = workspace.logoUrl && workspace.logoUrl !== failedSrc ? workspace.logoUrl : null\n\n  return (\n    <span\n      // The workspace name sits right next to it — announcing the avatar again\n      // would just duplicate it.\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md bg-primary/10 text-[10px] font-semibold text-primary\",\n        className,\n      )}\n    >\n      {src ? (\n        // eslint-disable-next-line @next/next/no-img-element -- registry component stays framework-agnostic, no next/image binding\n        <img alt=\"\" className=\"size-full object-cover\" onError={() => setFailedSrc(src)} src={src} />\n      ) : (\n        initialsOf(workspace.name)\n      )}\n    </span>\n  )\n}\n\nfunction PlanBadge({ children }: { children: React.ReactNode }) {\n  return (\n    <span className=\"shrink-0 truncate rounded border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground\">\n      {children}\n    </span>\n  )\n}\n\nconst FOOTER_ACTION_CLASS = cn(\n  \"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors\",\n  \"hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground\",\n  \"motion-reduce:transition-none\",\n)\n\n/**\n * A workspace / organization switcher: the trigger shows the active workspace,\n * the panel is a grouped, searchable menu with a pinned action footer.\n *\n * The panel is portalled to <body> with `position: fixed`, so no `overflow:\n * hidden` sidebar or card can clip it, and is then flipped / shifted / capped\n * against the nearest *scrollable* boundary (see `clipBounds`, `computeLayout`).\n *\n * Focus stays in the search box while the arrows drive the list through\n * `aria-activedescendant` — typing must never be stolen by a typeahead.\n *\n * The forwarded ref points at the trigger button.\n */\nexport const WorkspaceSwitcher = React.forwardRef<HTMLButtonElement, WorkspaceSwitcherProps>(\n  (\n    {\n      groups,\n      value,\n      onValueChange,\n      open: openProp,\n      onOpenChange,\n      align = \"start\",\n      searchThreshold = 5,\n      searchPlaceholder = \"Search workspaces…\",\n      emptyText = \"No workspaces found.\",\n      placeholder = \"Select a workspace\",\n      onCreate,\n      createLabel = \"Create workspace\",\n      onSettings,\n      settingsLabel = \"Workspace settings\",\n      className,\n      panelClassName,\n      onClick,\n      onKeyDown,\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const baseId = React.useId()\n    const panelId = `${baseId}-panel`\n    const triggerId = `${baseId}-trigger`\n    const isClient = useIsClient()\n\n    const triggerRef = React.useRef<HTMLButtonElement>(null)\n    React.useImperativeHandle(forwardedRef, () => triggerRef.current as HTMLButtonElement)\n    const panelRef = React.useRef<HTMLDivElement>(null)\n    const listRef = React.useRef<HTMLDivElement>(null)\n    const inputRef = React.useRef<HTMLInputElement>(null)\n    // Outside interactions must not yank focus back to the trigger — the user is\n    // already somewhere else. Esc, selection and trigger toggles do restore it.\n    const restoreFocusRef = React.useRef(true)\n\n    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)\n    const controlled = openProp !== undefined\n    const open = controlled ? openProp : uncontrolledOpen\n\n    // Latest-ref: consumers pass inline arrows, so keeping the callback out of\n    // every dependency array is what stops the effects below from detaching and\n    // re-attaching on every parent render. Ref writes belong in effects.\n    const onOpenChangeRef = React.useRef(onOpenChange)\n    React.useEffect(() => {\n      onOpenChangeRef.current = onOpenChange\n    })\n\n    const setOpen = React.useCallback(\n      (next: boolean) => {\n        if (!controlled) setUncontrolledOpen(next)\n        onOpenChangeRef.current?.(next)\n      },\n      [controlled],\n    )\n\n    const [query, setQuery] = React.useState(\"\")\n    const [layout, setLayout] = React.useState<PanelLayout | null>(null)\n    const [storedActiveId, setStoredActiveId] = React.useState(value)\n\n    // Render-phase adjust-state (never an effect): a closed panel forgets its\n    // placement so the next open cannot paint one frame at stale coordinates,\n    // reopens with a clean query, and highlights the workspace you are on.\n    const [prevOpen, setPrevOpen] = React.useState(open)\n    if (open !== prevOpen) {\n      setPrevOpen(open)\n      if (open) {\n        setQuery(\"\")\n        setStoredActiveId(value)\n      } else {\n        setLayout(null)\n      }\n    }\n\n    const normalizedQuery = query.trim().toLowerCase()\n    const totalCount = groups.reduce((sum, group) => sum + group.workspaces.length, 0)\n    // Clamped: a negative threshold would otherwise be honoured literally and a\n    // `searchThreshold={0}` must mean \"always show\", not \"never\".\n    const showSearch = totalCount > Math.max(0, searchThreshold)\n\n    const current = groups.flatMap(group => group.workspaces).find(workspace => workspace.id === value) ?? null\n\n    // Small lists (an org picker is tens of rows, not thousands): filtering every\n    // render is cheaper than pretending `groups` has a stable identity.\n    let flatIndex = 0\n    const sections = groups\n      .map((group, groupIndex) => ({\n        key: `${groupIndex}-${group.label}`,\n        label: group.label,\n        labelId: `${baseId}-group-${groupIndex}`,\n        workspaces: normalizedQuery\n          ? group.workspaces.filter(workspace => matchesQuery(workspace, normalizedQuery))\n          : group.workspaces,\n      }))\n      .filter(section => section.workspaces.length > 0)\n      .map(section => ({\n        ...section,\n        // Row ids are positional, so a workspace id containing whitespace or a\n        // duplicate can never break the aria-activedescendant IDREF.\n        rows: section.workspaces.map(workspace => ({ workspace, id: `${baseId}-row-${flatIndex++}` })),\n      }))\n\n    const enabledRows = sections.flatMap(section => section.rows).filter(row => !row.workspace.disabled)\n    // Derived, not stored: when filtering drops the highlighted row the fallback\n    // happens during render, so there is no setState-in-effect and no frame\n    // pointing aria-activedescendant at an id that left the DOM.\n    const activeRow = enabledRows.find(row => row.workspace.id === storedActiveId) ?? enabledRows[0] ?? null\n\n    const close = React.useCallback(\n      (restoreFocus: boolean) => {\n        restoreFocusRef.current = restoreFocus\n        setOpen(false)\n      },\n      [setOpen],\n    )\n\n    const selectWorkspace = (workspace: Workspace) => {\n      if (workspace.disabled) return\n      onValueChange(workspace.id)\n      close(true)\n    }\n\n    /* ---------------------------------------------------------------- *\n     * Positioning\n     * ---------------------------------------------------------------- */\n    React.useEffect(() => {\n      if (!open) return\n\n      let frame = 0\n      const apply = () => {\n        const anchor = triggerRef.current\n        const panel = panelRef.current\n        if (!anchor || !panel) return\n        const next = computeLayout(anchor, panel, listRef.current, align)\n        // Identity guard: the ResizeObserver re-fires once our own caps land on\n        // the panel. Bailing on an unchanged result turns that into a single\n        // no-op callback instead of a loop.\n        setLayout(previous => (sameLayout(previous, next) ? previous : next))\n      }\n      const schedule = (event: Event) => {\n        // Scrolling *inside* the row list changes nothing about where the panel\n        // goes, and remeasuring would pointlessly touch its scroll offset.\n        const target = event.target\n        if (target instanceof Node && panelRef.current?.contains(target)) return\n        if (frame) return\n        frame = requestAnimationFrame(() => {\n          frame = 0\n          apply()\n        })\n      }\n\n      // ResizeObserver fires once immediately after observe(), after layout and\n      // before paint — that first callback IS the initial measurement, so the\n      // panel is never painted at the wrong spot and nothing calls setState\n      // synchronously in this effect body.\n      let observer: ResizeObserver | null = null\n      if (typeof ResizeObserver !== \"undefined\") {\n        observer = new ResizeObserver(apply)\n        if (panelRef.current) observer.observe(panelRef.current)\n        if (triggerRef.current) observer.observe(triggerRef.current)\n      } else {\n        frame = requestAnimationFrame(() => {\n          frame = 0\n          apply()\n        })\n      }\n\n      // capture: true — `scroll` does not bubble, but the capture phase still\n      // passes through window for scrolls fired on any nested container, so the\n      // panel keeps tracking a trigger inside a scrollable sidebar.\n      window.addEventListener(\"scroll\", schedule, { capture: true, passive: true })\n      window.addEventListener(\"resize\", schedule)\n\n      return () => {\n        observer?.disconnect()\n        window.removeEventListener(\"scroll\", schedule, { capture: true })\n        window.removeEventListener(\"resize\", schedule)\n        if (frame) cancelAnimationFrame(frame)\n      }\n      // `enabledRows.length` re-arms the measurement when filtering changes the\n      // natural height while the clamped box stays identical (the observer alone\n      // would never fire in that case).\n    }, [open, align, enabledRows.length, sections.length])\n\n    /* ---------------------------------------------------------------- *\n     * Focus\n     * ---------------------------------------------------------------- */\n    // Read at call time, never captured when the effect runs: the trigger node\n    // can be replaced between open and close. isConnected because switching\n    // workspace routinely unmounts it, and focusing a detached node silently\n    // drops focus onto <body> instead of throwing.\n    const restoreTriggerFocus = React.useCallback(() => {\n      const back = triggerRef.current\n      if (back?.isConnected) back.focus({ preventScroll: true })\n    }, [])\n\n    React.useEffect(() => {\n      if (!open) return\n      restoreFocusRef.current = true\n\n      // preventScroll: the first frame has no coordinates yet, so focusing\n      // without it would scroll the page toward a panel parked at 0,0.\n      const target = inputRef.current ?? listRef.current ?? panelRef.current\n      target?.focus({ preventScroll: true })\n\n      return () => {\n        if (restoreFocusRef.current) restoreTriggerFocus()\n      }\n    }, [open, restoreTriggerFocus])\n\n    /* ---------------------------------------------------------------- *\n     * Dismissal\n     * ---------------------------------------------------------------- */\n    React.useEffect(() => {\n      if (!open) return\n      // pointerdown, not click: the panel must be gone before the press turns\n      // into a click on whatever sits underneath.\n      const handlePointerDown = (event: PointerEvent) => {\n        const target = event.target\n        if (!(target instanceof Node)) return\n        if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return\n        close(false)\n      }\n      document.addEventListener(\"pointerdown\", handlePointerDown)\n      return () => document.removeEventListener(\"pointerdown\", handlePointerDown)\n    }, [open, close])\n\n    /* ---------------------------------------------------------------- *\n     * Scroll lock\n     * ---------------------------------------------------------------- */\n    React.useEffect(() => {\n      if (!open) return\n      lockScroll()\n      return releaseScroll\n    }, [open])\n\n    /* ---------------------------------------------------------------- *\n     * Keep the highlighted row visible\n     * ---------------------------------------------------------------- */\n    const activeRowId = activeRow?.id ?? null\n    React.useEffect(() => {\n      if (!open) return\n      const scroller = listRef.current\n      const row = scroller?.querySelector<HTMLElement>('[data-active=\"true\"]')\n      if (!scroller || !row) return\n      // Manual scrolling rather than scrollIntoView: the latter also walks up and\n      // scrolls ancestors, which would move the page behind the panel.\n      const top = row.offsetTop\n      const bottom = top + row.offsetHeight\n      if (top < scroller.scrollTop) scroller.scrollTop = Math.max(0, top - 4)\n      else if (bottom > scroller.scrollTop + scroller.clientHeight) {\n        scroller.scrollTop = bottom - scroller.clientHeight + 4\n      }\n    }, [open, activeRowId])\n\n    const moveActive = (delta: number) => {\n      if (enabledRows.length === 0) return\n      const index = enabledRows.findIndex(row => row.id === activeRowId)\n      const next = (((index < 0 ? 0 : index + delta) % enabledRows.length) + enabledRows.length) % enabledRows.length\n      setStoredActiveId(enabledRows[next].workspace.id)\n    }\n\n    const handlePanelKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault()\n        // stopPropagation so a switcher inside another overlay closes the\n        // innermost surface only.\n        event.stopPropagation()\n        close(true)\n        return\n      }\n\n      if (event.key === \"Tab\") {\n        // Real trap: the panel is portalled to <body>, so Tab would otherwise\n        // land in an unrelated corner of the document while the panel is still\n        // open and claiming aria-modal.\n        const panel = panelRef.current\n        if (!panel) return\n        const focusables = focusablesIn(panel)\n        if (focusables.length === 0) {\n          event.preventDefault()\n          panel.focus({ preventScroll: true })\n          return\n        }\n        const first = focusables[0]\n        const last = focusables[focusables.length - 1]\n        const active = document.activeElement\n        // The bare row list (and the panel itself) are tabIndex -1, so they are\n        // not in `focusables`; without this branch Shift+Tab from the list would\n        // walk straight out of the portalled panel.\n        if (!(active instanceof HTMLElement) || !focusables.includes(active)) {\n          event.preventDefault()\n          ;(event.shiftKey ? last : first).focus()\n        } else if (event.shiftKey && active === first) {\n          event.preventDefault()\n          last.focus()\n        } else if (!event.shiftKey && active === last) {\n          event.preventDefault()\n          first.focus()\n        }\n        return\n      }\n\n      // Everything below drives the row list. A footer button owns its own\n      // Enter/Space, so list keys only apply while focus is on the search box\n      // (or on the list itself when there is no search box).\n      const target = event.target\n      const drivesList = target === inputRef.current || target === listRef.current\n      if (!drivesList) return\n\n      switch (event.key) {\n        case \"ArrowDown\":\n          event.preventDefault()\n          moveActive(1)\n          return\n        case \"ArrowUp\":\n          event.preventDefault()\n          moveActive(-1)\n          return\n        case \"Home\":\n        case \"End\":\n          // In the search box these belong to the caret; only the bare list\n          // treats them as jump-to-end.\n          if (target === inputRef.current) return\n          event.preventDefault()\n          if (enabledRows.length === 0) return\n          setStoredActiveId(enabledRows[event.key === \"Home\" ? 0 : enabledRows.length - 1].workspace.id)\n          return\n        case \"Enter\":\n          event.preventDefault()\n          if (activeRow) selectWorkspace(activeRow.workspace)\n          return\n        default:\n          // Printable characters fall through to the search input — this menu\n          // deliberately has no typeahead to steal them.\n          break\n      }\n    }\n\n    const hasFooter = Boolean(onCreate || onSettings)\n\n    return (\n      <>\n        <button\n          aria-controls={open ? panelId : undefined}\n          aria-expanded={open}\n          aria-haspopup=\"dialog\"\n          className={cn(\n            \"flex w-full max-w-full cursor-pointer items-center gap-2 rounded-lg border bg-background p-2 text-left text-sm transition-colors\",\n            \"hover:bg-accent hover:text-accent-foreground\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            \"motion-reduce:transition-none\",\n            className,\n          )}\n          data-state={open ? \"open\" : \"closed\"}\n          id={triggerId}\n          onClick={event => {\n            onClick?.(event)\n            if (event.defaultPrevented) return\n            restoreFocusRef.current = true\n            setOpen(!open)\n          }}\n          onKeyDown={event => {\n            onKeyDown?.(event)\n            if (event.defaultPrevented) return\n            if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n              event.preventDefault()\n              restoreFocusRef.current = true\n              setOpen(true)\n            } else if (open && event.key === \"Escape\") {\n              // Reachable when a pointer press moved focus back to the trigger\n              // while the panel stayed open.\n              event.preventDefault()\n              close(true)\n            }\n          }}\n          ref={triggerRef}\n          type=\"button\"\n          {...rest}\n        >\n          {current ? (\n            <>\n              <WorkspaceAvatar workspace={current} />\n              {/* min-w-0 on both the flex child and the label is what lets a long\n                  name ellipsise instead of pushing the chevron out of a narrow\n                  sidebar. */}\n              <span className=\"min-w-0 flex-1 truncate font-medium\">{current.name}</span>\n              {current.plan ? <PlanBadge>{current.plan}</PlanBadge> : null}\n            </>\n          ) : (\n            <>\n              <span\n                aria-hidden=\"true\"\n                className=\"flex size-6 shrink-0 items-center justify-center rounded-md border border-dashed text-muted-foreground\"\n              >\n                <Plus className=\"size-3\" />\n              </span>\n              <span className=\"min-w-0 flex-1 truncate text-muted-foreground\">{placeholder}</span>\n            </>\n          )}\n          <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n        </button>\n\n        {isClient &&\n          open &&\n          createPortal(\n            <>\n              <style href=\"zyeon-workspace-switcher\" precedence=\"medium\">\n                {KEYFRAMES}\n              </style>\n\n              <div\n                aria-label=\"Workspace switcher\"\n                aria-modal=\"true\"\n                className={cn(\n                  \"fixed z-50 flex w-72 flex-col overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md outline-none\",\n                  // No coordinates on the first frame: `opacity-0` hides it while\n                  // keeping it measurable *and* focusable — `visibility: hidden`\n                  // would silently drop the focus() above.\n                  layout ? ENTER_ANIMATION[layout.side] : \"opacity-0\",\n                  \"motion-reduce:[animation:none]\",\n                  panelClassName,\n                )}\n                data-side={layout?.side ?? \"bottom\"}\n                id={panelId}\n                onKeyDown={handlePanelKeyDown}\n                ref={panelRef}\n                role=\"dialog\"\n                style={{\n                  left: layout?.left ?? 0,\n                  top: layout?.top ?? 0,\n                  maxWidth: layout?.maxWidth,\n                  maxHeight: layout?.maxHeight,\n                }}\n                tabIndex={-1}\n              >\n                {showSearch ? (\n                  <div className=\"flex shrink-0 items-center gap-2 border-b px-3\">\n                    <Search aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                    <input\n                      aria-activedescendant={activeRow?.id}\n                      aria-controls={sections.length > 0 ? `${baseId}-menu` : undefined}\n                      aria-label={searchPlaceholder}\n                      autoComplete=\"off\"\n                      className=\"h-10 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n                      onChange={event => setQuery(event.target.value)}\n                      placeholder={searchPlaceholder}\n                      ref={inputRef}\n                      spellCheck=\"false\"\n                      type=\"text\"\n                      value={query}\n                    />\n                  </div>\n                ) : null}\n\n                {sections.length === 0 ? (\n                  <p className=\"px-3 py-6 text-center text-sm text-muted-foreground\" role=\"status\">\n                    {emptyText}\n                  </p>\n                ) : (\n                  <div\n                    // `relative` makes this the offsetParent of every row, which\n                    // is what the keep-visible effect measures against.\n                    aria-activedescendant={showSearch ? undefined : activeRow?.id}\n                    aria-label=\"Workspaces\"\n                    className=\"relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-1\"\n                    id={`${baseId}-menu`}\n                    ref={listRef}\n                    role=\"menu\"\n                    tabIndex={showSearch ? undefined : -1}\n                  >\n                    {sections.map(section => (\n                      // role=\"group\" + aria-labelledby keeps the menu → menuitem\n                      // ownership chain intact: a bare div in between makes screen\n                      // readers announce a menu that owns nothing.\n                      <div aria-labelledby={section.labelId} className=\"mb-1 last:mb-0\" key={section.key} role=\"group\">\n                        <div\n                          className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\"\n                          id={section.labelId}\n                          role=\"presentation\"\n                        >\n                          {section.label}\n                        </div>\n\n                        {section.rows.map(({ id, workspace }) => {\n                          const checked = workspace.id === value\n                          const isActive = id === activeRowId\n                          return (\n                            <button\n                              aria-checked={checked}\n                              aria-disabled={workspace.disabled ? true : undefined}\n                              className={cn(\n                                \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors\",\n                                workspace.disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\",\n                                isActive && !workspace.disabled && \"bg-accent text-accent-foreground\",\n                                \"motion-reduce:transition-none\",\n                              )}\n                              data-active={isActive ? \"true\" : undefined}\n                              id={id}\n                              key={id}\n                              onClick={() => selectWorkspace(workspace)}\n                              // Keep the caret (and DOM focus) in the search box:\n                              // the highlight is aria-activedescendant, not focus.\n                              onMouseDown={event => event.preventDefault()}\n                              onPointerEnter={() => {\n                                if (!workspace.disabled) setStoredActiveId(workspace.id)\n                              }}\n                              role=\"menuitemradio\"\n                              // Not a tab stop: Tab cycles trigger-less between the\n                              // search box and the footer actions.\n                              tabIndex={-1}\n                              type=\"button\"\n                            >\n                              <WorkspaceAvatar workspace={workspace} />\n                              <span className=\"min-w-0 flex-1 truncate\">{workspace.name}</span>\n                              {workspace.plan ? <PlanBadge>{workspace.plan}</PlanBadge> : null}\n                              <span\n                                aria-hidden=\"true\"\n                                className=\"flex size-4 shrink-0 items-center justify-center text-primary\"\n                              >\n                                {checked ? <Check className=\"size-4\" /> : null}\n                              </span>\n                            </button>\n                          )\n                        })}\n                      </div>\n                    ))}\n                  </div>\n                )}\n\n                {hasFooter ? (\n                  // Outside the scroll area on purpose: \"Create workspace\" must\n                  // stay reachable after typing a query that matches nothing.\n                  <div className=\"shrink-0 border-t p-1\">\n                    {onCreate ? (\n                      <button\n                        className={FOOTER_ACTION_CLASS}\n                        onClick={() => {\n                          close(true)\n                          onCreate()\n                        }}\n                        type=\"button\"\n                      >\n                        <Plus aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                        <span className=\"min-w-0 flex-1 truncate\">{createLabel}</span>\n                      </button>\n                    ) : null}\n                    {onSettings ? (\n                      <button\n                        className={FOOTER_ACTION_CLASS}\n                        onClick={() => {\n                          close(true)\n                          onSettings()\n                        }}\n                        type=\"button\"\n                      >\n                        <Settings aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                        <span className=\"min-w-0 flex-1 truncate\">{settingsLabel}</span>\n                      </button>\n                    ) : null}\n                  </div>\n                ) : null}\n              </div>\n            </>,\n            document.body,\n          )}\n      </>\n    )\n  },\n)\n\nWorkspaceSwitcher.displayName = \"WorkspaceSwitcher\"\n\nexport default WorkspaceSwitcher\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}