{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-switcher",
  "title": "App Switcher",
  "description": "Switcher for the top of a sidebar: a mark and space name that open a menu of spaces.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/badge",
    "@pestoui/dropdown-menu",
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/components/app-switcher.tsx",
      "content": "import * as React from \"react\"\nimport { CheckIcon, ChevronsUpDownIcon } from \"lucide-react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\n\n/* ---------------------------------------------------------------------------\n   The switcher at the top of a sidebar: a mark, the space name, an optional\n   plan stamp, and a menu of the other spaces.\n\n   Drawn as a bordered card rather than as the first row of the nav, and that\n   choice is the whole component. Every row below it moves *within* one space;\n   this one changes which space the rest of the sidebar — and every number on\n   every page — is about. Given the same silhouette as a nav row it collects the\n   click that is hardest to notice and hardest to undo, because the scope\n   changes without anything looking like it changed.\n\n   Card tokens, not `sidebar-*` ones: this is a card sitting *on* the sidebar,\n   so it keeps its own surface when the panel around it is retinted.\n\n   Compound rather than `spaces={[…]}`, because `mark` is the slot that earns\n   the component — a tinted chip, an <Avatar>, a brand glyph — and a data prop\n   would push every one of those through a render callback.\n\n   The menu is `children`, not a prop, so trigger and panel stay one subtree and\n   Radix wires them together:\n\n     <AppSwitcher mark={<span className=\"bg-chart-1\" />} label=\"Space 0172\"\n                  plan=\"PRO\">\n       <AppSwitcherMenu label=\"Spaces\">\n         <AppSwitcherItem mark={…} label=\"North region\" active />\n         <AppSwitcherFooter>\n           <DropdownMenuItem>New space</DropdownMenuItem>\n         </AppSwitcherFooter>\n       </AppSwitcherMenu>\n     </AppSwitcher>\n\n   Nothing here holds state. Which space is current arrives as `active`, and\n   selection is the caller's `onSelect`.\n--------------------------------------------------------------------------- */\n\n/* The mark's frame, shared by the trigger and by the menu rows so one space\n   keeps the same silhouette in both places. Every radius stays under half its\n   edge — past that a fixed square box renders as a circle. */\nconst appSwitcherMark = {\n  sm: \"size-7 rounded-md\",\n  default: \"size-9 rounded-lg\",\n  lg: \"size-10 rounded-lg\",\n} as const\n\n/* A single non-svg child is stretched to fill, so a bare coloured <span> needs\n   no size of its own, while an icon keeps its own scale instead of being blown\n   up to the width of the chip. */\nconst appSwitcherMarkFrame = [\n  \"flex shrink-0 items-center justify-center overflow-hidden\",\n  \"[&>*:not(svg)]:size-full [&>svg]:size-4\",\n].join(\" \")\n\nconst appSwitcherVariants = cva(\n  [\n    \"group/app-switcher flex w-full items-center border border-border bg-card\",\n    \"text-left outline-none transition-[background-color,transform]\",\n    \"duration-instant ease-out-expo hover:bg-accent data-open:bg-accent\",\n    \"focus-visible:ring-3 focus-visible:ring-ring/50\",\n    /* The press dip is worth its cost on this one control: it reframes the\n       page, and a card that yields under the pointer confirms the click landed\n       before the menu has had time to arrive. */\n    \"active:scale-99 active:duration-spring-press active:ease-spring-press\",\n    \"disabled:pointer-events-none disabled:opacity-50\",\n  ],\n  {\n    variants: {\n      /* `sm` steps its radius down with the box. `rounded-xl` is 22.4px and the\n         small card is 40px tall, so the corner would run past half the edge and\n         collapse the card into a pill — the one silhouette a switcher must not\n         borrow, because it is what a badge looks like. */\n      size: {\n        sm: \"gap-2 rounded-lg p-1.5 text-sm\",\n        default: \"gap-2.5 rounded-xl p-2 text-sm\",\n        lg: \"gap-3 rounded-xl p-2.5 text-base\",\n      },\n    },\n    defaultVariants: { size: \"default\" },\n  },\n)\n\nconst appSwitcherItemVariants = cva(\"gap-2.5 px-2 py-1.5\", {\n  variants: {\n    /* Weight, never a fill. The row's background belongs to focus — paint the\n       current space with one too and a keyboard user can no longer tell where\n       the cursor is from what is already selected. */\n    active: {\n      true: \"font-medium\",\n      false: \"\",\n    },\n  },\n  defaultVariants: { active: false },\n})\n\ntype AppSwitcherSize = NonNullable<VariantProps<typeof appSwitcherVariants>[\"size\"]>\n\n/* Uppercased rather than passed through: callers will not agree on \"Pro\" vs\n   \"PRO\", and a three-letter stamp in mixed case reads as a typo. */\nfunction AppSwitcherPlan({ children }: { children: React.ReactNode }) {\n  return (\n    <Badge\n      variant=\"secondary\"\n      className=\"h-5 shrink-0 px-1.5 text-xs font-semibold tracking-wide uppercase\"\n    >\n      {children}\n    </Badge>\n  )\n}\n\ntype AppSwitcherProps = React.ComponentProps<\"button\"> & {\n  /** Leading identity chip — a tinted `<span>`, an `<Avatar>`, a brand glyph.\n   *  Sized and clipped by the frame, so it needs no dimensions of its own. */\n  mark?: React.ReactNode\n  /** The space name. Truncates; the plan stamp beside it never does. */\n  label: React.ReactNode\n  /** Short plan stamp, e.g. \"FREE\" / \"PRO\". Rendered as a quiet `<Badge>`. */\n  plan?: string\n  /** Announced ahead of the name, for callers who want the action named. The\n   *  trigger already reports `aria-haspopup`, so this is a refinement. */\n  srLabel?: string\n  size?: AppSwitcherSize\n}\n\nfunction AppSwitcher({\n  className,\n  size = \"default\",\n  mark,\n  label,\n  plan,\n  srLabel,\n  children,\n  ...props\n}: AppSwitcherProps) {\n  const trigger = (\n    <button\n      type=\"button\"\n      data-slot=\"app-switcher\"\n      data-size={size}\n      className={cn(appSwitcherVariants({ size }), className)}\n      {...props}\n    >\n      {srLabel ? <span className=\"sr-only\">{srLabel}</span> : null}\n      {mark ? (\n        /* aria-hidden: the name beside it already identifies the space, so\n           announcing the chip would only repeat it. */\n        <span\n          data-slot=\"app-switcher-mark\"\n          aria-hidden=\"true\"\n          className={cn(appSwitcherMarkFrame, appSwitcherMark[size])}\n        >\n          {mark}\n        </span>\n      ) : null}\n      <span className=\"flex min-w-0 flex-1 items-center gap-1.5\">\n        <span data-slot=\"app-switcher-label\" className=\"truncate font-medium\">\n          {label}\n        </span>\n        {plan ? <AppSwitcherPlan>{plan}</AppSwitcherPlan> : null}\n      </span>\n      {/* Two arrows rather than one: this control swaps a value, it does not\n          expand a section, and a lone chevron promises the second thing. */}\n      <ChevronsUpDownIcon\n        aria-hidden=\"true\"\n        className={cn(\n          \"size-4 shrink-0 text-muted-foreground transition-colors\",\n          \"duration-instant ease-out-expo\",\n          \"group-hover/app-switcher:text-foreground\",\n        )}\n      />\n    </button>\n  )\n\n  /* No menu, no Radix. A trigger reporting `aria-haspopup=\"menu\"` that opens\n     nothing is worse than a plain button, and a switcher with a single space is\n     a real state. Which branch renders is decided by the shape of the call site\n     and never flips at runtime, so the swap costs no remount. */\n  if (!children) {\n    return trigger\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>\n      {children}\n    </DropdownMenu>\n  )\n}\n\ntype AppSwitcherMenuProps = React.ComponentProps<typeof DropdownMenuContent> & {\n  /** Visible heading above the list. */\n  label?: React.ReactNode\n}\n\n/* Inherits `DropdownMenuContent`'s trigger-width sizing, which is what makes\n   the panel read as the card unfolding rather than as a menu arriving from\n   somewhere else. `min-w-56` is the floor for a sidebar collapsed to a rail,\n   where the trigger is a square and that width would be unreadable. */\nfunction AppSwitcherMenu({\n  className,\n  label,\n  align = \"start\",\n  sideOffset = 6,\n  children,\n  ...props\n}: AppSwitcherMenuProps) {\n  return (\n    <DropdownMenuContent\n      data-slot=\"app-switcher-menu\"\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\"min-w-56\", className)}\n      {...props}\n    >\n      {label ? <DropdownMenuLabel>{label}</DropdownMenuLabel> : null}\n      {children}\n    </DropdownMenuContent>\n  )\n}\n\ntype AppSwitcherItemProps = React.ComponentProps<typeof DropdownMenuItem> &\n  VariantProps<typeof appSwitcherItemVariants> & {\n    mark?: React.ReactNode\n    label: React.ReactNode\n    plan?: string\n  }\n\nfunction AppSwitcherItem({\n  className,\n  mark,\n  label,\n  plan,\n  active = false,\n  ...props\n}: AppSwitcherItemProps) {\n  return (\n    <DropdownMenuItem\n      data-slot=\"app-switcher-item\"\n      data-active={active}\n      /* This is what actually announces the current space; the check below is\n         decorative. `page` would be the wrong value — a space is a scope, not a\n         location, and every row in the menu belongs to the same page. */\n      aria-current={active ? \"true\" : undefined}\n      className={cn(appSwitcherItemVariants({ active }), className)}\n      {...props}\n    >\n      {mark ? (\n        <span\n          data-slot=\"app-switcher-mark\"\n          aria-hidden=\"true\"\n          className={cn(appSwitcherMarkFrame, appSwitcherMark.sm)}\n        >\n          {mark}\n        </span>\n      ) : null}\n      <span data-slot=\"app-switcher-item-label\" className=\"min-w-0 flex-1 truncate\">\n        {label}\n      </span>\n      {plan ? <AppSwitcherPlan>{plan}</AppSwitcherPlan> : null}\n      {/* The slot is drawn on every row so the plan stamps line up down the\n          column instead of stepping sideways past the rows with no check. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"flex size-4 shrink-0 items-center justify-center\"\n      >\n        {active ? <CheckIcon /> : null}\n      </span>\n    </DropdownMenuItem>\n  )\n}\n\n/* A separator plus a group, not a bare `<div>`: `role=\"menu\"` admits only menu\n   items, groups and separators, so an unlabelled wrapper element would drop the\n   rows inside it out of the menu's own structure. */\nfunction AppSwitcherFooter(props: React.ComponentProps<typeof DropdownMenuGroup>) {\n  return (\n    <>\n      <DropdownMenuSeparator />\n      <DropdownMenuGroup data-slot=\"app-switcher-footer\" {...props} />\n    </>\n  )\n}\n\nexport {\n  AppSwitcher,\n  AppSwitcherFooter,\n  AppSwitcherItem,\n  AppSwitcherMenu,\n  appSwitcherItemVariants,\n  appSwitcherVariants,\n}\nexport type { AppSwitcherSize }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}