{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidebar",
  "type": "registry:ui",
  "title": "Sidebar",
  "description": "An accessible React sidebar for Next.js and Tailwind CSS: collapses to icons or off canvas, with a gliding active indicator and a sheet on phones.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.52.0",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json",
    "https://ui.fewtone.com/r/sheet.json",
    "https://ui.fewtone.com/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/sidebar.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Sheet, SheetContent, SheetTitle } from \"@/components/ui/sheet\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { blur, duration, ease, spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { ChevronRightIcon } from \"lucide-react\";\nimport { type AnimationPlaybackControls, animate, motion, motionValue, useReducedMotion } from \"motion/react\";\nimport { Collapsible as CollapsiblePrimitive, Slot } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  type CSSProperties,\n  createContext,\n  type MouseEvent,\n  type Ref,\n  type RefObject,\n  type SyntheticEvent,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\n\n/*\n * Sidebar: a collapsible app sidebar (roadmap, 2026-10-16: \"A collapsible app sidebar with a gliding active\n * indicator\"), with shadcn's parts and `useSidebar`.\n * - SidebarProvider keeps the open state (controlled or not). SidebarTrigger, SidebarRail and ⌘B / Ctrl+B\n *   toggle it; the shortcut leaves typing alone. Below Tailwind's md (768px) the sidebar opens as a Sheet.\n * - The panel sits in the page's flow, sticky to the top, rather than fixed, so the content beside it makes\n *   room and the whole shell also works inside a frame. Widths are variables on the provider, as in shadcn.\n * - `collapsible`: \"icon\" narrows it to its icons (labels become tooltips), \"offcanvas\" slides it away, \"none\"\n *   keeps it.\n * - Motion (.agents/rules/motion.md):\n *   - One highlight glides to the current page's row (spring.snappy), and one more follows the mouse and\n *     fades out where it leaves. Both are placed from layout offsets, on transform only, so they stay right\n *     while the list scrolls. When the layout moves under them (a section opening, the width gliding) they\n *     keep to their row at once; they glide only when their row changes. A change of size glides as a scale.\n *   - A current page inside a folded section (closed, or the sidebar down to its icons) shows on the\n *     section's own row: the highlight climbs to it as the section folds, and drops back as it opens.\n *   - Narrowing to icons glides the width on duration.slow + ease.out (the height pattern's tween, never a\n *     spring), while the labels leave through the blur and later come back through it.\n *   - Sections open and close with the shipped collapsible keyframes, and their chevron turns.\n *   - Nothing animates on first paint, and with reduced motion it's all instant.\n */\n\n/** Widths, set as variables on SidebarProvider (change them through its `style`) */\nconst SIDEBAR_WIDTH = \"16rem\";\nconst SIDEBAR_WIDTH_ICON = \"3rem\";\n/** The phone sheet is a little wider, for thumbs */\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\";\n/** Below Tailwind's md the sidebar is a sheet; the desktop panel is `md:block` to match before hydration */\nconst MOBILE_QUERY = \"(max-width: 767.98px)\";\n\nfunction subscribeToWidth(onChange: () => void) {\n  const query = window.matchMedia(MOBILE_QUERY);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\n/** Phone-sized, from the media query. The server and hydration assume a wider screen. */\nfunction useIsMobile() {\n  return useSyncExternalStore(\n    subscribeToWidth,\n    () => window.matchMedia(MOBILE_QUERY).matches,\n    () => false,\n  );\n}\n\ntype SidebarContextValue = {\n  /** \"collapsed\" while the desktop sidebar is narrowed to its icons or slid away */\n  state: \"expanded\" | \"collapsed\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  /** The phone sheet's own open state */\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  /** Opens or closes the sheet on phones, the sidebar elsewhere */\n  toggleSidebar: () => void;\n  /** The sidebar's id, for the trigger's aria-controls */\n  id: string;\n  shortcut: string | false;\n};\n\nconst SidebarContext = createContext<SidebarContextValue | null>(null);\n\n/** The sidebar's state and controls, for your own trigger or anything that reacts to it */\nfunction useSidebar() {\n  const context = useContext(SidebarContext);\n  if (!context) throw new Error(\"useSidebar and the Sidebar parts must be used inside SidebarProvider\");\n  return context;\n}\n\ntype Collapsible = \"offcanvas\" | \"icon\" | \"none\";\n\n/** What the parts inside one Sidebar know about it */\nconst PanelContext = createContext<{\n  side: \"left\" | \"right\";\n  collapsible: Collapsible;\n  /** Down to its icons: labels have left, sub-menus have folded, tooltips name the rows */\n  iconMode: boolean;\n} | null>(null);\n\n/** SidebarContent's highlights: rows report hover, and changes to which one is current */\nconst GlideContext = createContext<{\n  /** Placed: the highlights now draw what each row drew itself before (server HTML, first paint) */\n  ready: boolean;\n  /** A row became or stopped being current, or came or went: find the current one again */\n  invalidate: () => void;\n  enter: (row: HTMLElement) => void;\n  leave: (row: HTMLElement) => void;\n} | null>(null);\n\n/** One SidebarMenuCollapsible: whether it's open, and whether it's showing (it folds while down to icons) */\nconst SectionContext = createContext<{\n  open: boolean;\n  shown: boolean;\n  setOpen: (open: boolean) => void;\n} | null>(null);\n\nfunction useSection(part: string) {\n  const section = useContext(SectionContext);\n  if (!section) throw new Error(`${part} must be used inside SidebarMenuCollapsible`);\n  return section;\n}\n\n/** Hands a node to our ref and to the caller's */\nfunction useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) {\n  return useCallback(\n    (node: T | null) => {\n      own.current = node;\n      if (typeof theirs === \"function\") theirs(node);\n      else if (theirs) theirs.current = node;\n    },\n    [own, theirs],\n  );\n}\n\n/** The shortcut leaves typing alone: ⌘B means bold in most editors */\nfunction isTyping(target: EventTarget | null) {\n  return (\n    target instanceof HTMLElement &&\n    (target.isContentEditable ||\n      target.closest('input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])') !== null)\n  );\n}\n\nexport type SidebarProviderProps = ComponentProps<\"div\"> & {\n  /** Whether the sidebar is open, when you control it */\n  open?: boolean;\n  /** Whether it starts open */\n  defaultOpen?: boolean;\n  /** Called when it opens or closes (the phone sheet keeps its own state) */\n  onOpenChange?: (open: boolean) => void;\n  /** The letter that toggles it with ⌘ or Ctrl; false turns the shortcut off */\n  shortcut?: string | false;\n};\n\nfunction SidebarProvider({\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  shortcut = \"b\",\n  className,\n  style,\n  children,\n  ...props\n}: SidebarProviderProps) {\n  const isMobile = useIsMobile();\n  const id = useId();\n  const [own, setOwn] = useState(defaultOpen);\n  const open = openProp ?? own;\n  const [openMobile, setOpenMobile] = useState(false);\n  // Growing past phone size closes the sheet, so it doesn't spring open again on the way back\n  if (!isMobile && openMobile) setOpenMobile(false);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      setOwn(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n\n  const toggleSidebar = useCallback(() => {\n    if (isMobile) setOpenMobile((current) => !current);\n    else setOpen(!open);\n  }, [isMobile, open, setOpen]);\n\n  useEffect(() => {\n    if (!shortcut) return;\n    const key = shortcut.toLowerCase();\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key?.toLowerCase() !== key || !(event.metaKey || event.ctrlKey)) return;\n      if (event.altKey || event.shiftKey || event.repeat || event.defaultPrevented || isTyping(event.target))\n        return;\n      event.preventDefault();\n      toggleSidebar();\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [shortcut, toggleSidebar]);\n\n  const value = useMemo<SidebarContextValue>(\n    () => ({\n      state: open ? \"expanded\" : \"collapsed\",\n      open,\n      setOpen,\n      openMobile,\n      setOpenMobile,\n      isMobile,\n      toggleSidebar,\n      id,\n      shortcut,\n    }),\n    [open, setOpen, openMobile, isMobile, toggleSidebar, id, shortcut],\n  );\n\n  return (\n    <SidebarContext.Provider value={value}>\n      {/* The rows' tooltips hand over to each other down the icon column */}\n      <TooltipProvider>\n        <div\n          data-slot=\"sidebar-wrapper\"\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH,\n              \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as CSSProperties\n          }\n          // overflow-x-clip: a sidebar slid away stays off the page without making it scroll sideways, and\n          // (unlike hidden) it keeps the sticky panel stuck to the viewport\n          className={cn(\n            \"group/sidebar-wrapper flex min-h-svh w-full overflow-x-clip bg-background text-foreground\",\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  );\n}\n\n/** Everything that leaves when the sidebar narrows to its icons: each row's label (all but its icon), group labels and badges */\nconst LABELS =\n  \"[data-slot=sidebar-menu-button] > :not(:first-child), [data-slot=sidebar-group-label], [data-slot=sidebar-menu-badge]\";\n\nfunction clearStyles(labels: HTMLElement[]) {\n  for (const label of labels) {\n    label.style.removeProperty(\"opacity\");\n    label.style.removeProperty(\"filter\");\n  }\n}\n\n/**\n * Narrowing to icons, the labels leave through the blur; widening, they come back through it. The classes\n * hold each end state, so this only plays the change and then clears what it wrote (ADR 0011).\n */\nfunction useLabelsBlur(nav: RefObject<HTMLElement | null>, iconMode: boolean, reduce: boolean) {\n  const last = useRef(iconMode);\n  const run = useRef<{ controls: AnimationPlaybackControls; labels: HTMLElement[] } | null>(null);\n\n  useLayoutEffect(() => {\n    // Nothing on first paint, nor when nothing changed\n    if (last.current === iconMode) return;\n    last.current = iconMode;\n\n    // Turned back mid-way, the labels carry on from where they are\n    let from: { opacity: number; filter: string } = iconMode\n      ? { opacity: 1, filter: blur.shown }\n      : { opacity: 0, filter: blur.hidden };\n    const previous = run.current;\n    if (previous?.labels[0]) {\n      const style = getComputedStyle(previous.labels[0]);\n      from = {\n        opacity: style.opacity === \"\" ? from.opacity : Number(style.opacity),\n        filter: style.filter && style.filter !== \"none\" ? style.filter : blur.shown,\n      };\n    }\n    previous?.controls.stop();\n    if (previous) clearStyles(previous.labels);\n    run.current = null;\n\n    const labels = Array.from(nav.current?.querySelectorAll<HTMLElement>(LABELS) ?? []);\n    if (reduce || labels.length === 0) return;\n    const controls = animate(\n      labels,\n      {\n        opacity: [from.opacity, iconMode ? 0 : 1],\n        filter: [from.filter, iconMode ? blur.hidden : blur.shown],\n      },\n      // Leaving is quick, so the text has gone before the width closes on it; coming back keeps pace\n      iconMode ? { duration: duration.fast, ease: ease.in } : { duration: duration.slow, ease: ease.out },\n    );\n    const current = { controls, labels };\n    run.current = current;\n    controls.then(() => {\n      if (run.current !== current) return;\n      run.current = null;\n      clearStyles(labels);\n    });\n  }, [iconMode, reduce, nav]);\n\n  useEffect(() => () => run.current?.controls.stop(), []);\n}\n\nexport type SidebarProps = ComponentProps<\"nav\"> & {\n  /** The edge it's on. Put a right sidebar after SidebarInset. */\n  side?: \"left\" | \"right\";\n  /** What collapsing does: narrow it to its icons, slide it away, or nothing */\n  collapsible?: Collapsible;\n};\n\n/** The sidebar: a navigation landmark (named \"Sidebar\" unless you give it an aria-label) */\nfunction Sidebar({\n  side = \"left\",\n  collapsible = \"offcanvas\",\n  className,\n  children,\n  ref,\n  \"aria-label\": label = \"Sidebar\",\n  ...props\n}: SidebarProps) {\n  const sidebar = useSidebar();\n  const reduce = useReducedMotion() ?? false;\n  const node = useRef<HTMLElement>(null);\n  const setRef = useMergedRef(node, ref);\n  const collapsed = sidebar.state === \"collapsed\";\n  const iconMode = !sidebar.isMobile && collapsible === \"icon\" && collapsed;\n  const panel = useMemo(() => ({ side, collapsible, iconMode }), [side, collapsible, iconMode]);\n  useLabelsBlur(node, iconMode, reduce);\n\n  if (collapsible === \"none\") {\n    return (\n      <PanelContext.Provider value={panel}>\n        <nav\n          ref={setRef}\n          id={sidebar.id}\n          data-slot=\"sidebar\"\n          data-side={side}\n          aria-label={label}\n          className={cn(\n            \"flex h-full w-(--sidebar-width) flex-col bg-muted/50 text-foreground\", // check-tokens-ignore: the sidebar's own width, set on SidebarProvider\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </nav>\n      </PanelContext.Provider>\n    );\n  }\n\n  if (sidebar.isMobile) {\n    return (\n      <PanelContext.Provider value={panel}>\n        <Sheet open={sidebar.openMobile} onOpenChange={sidebar.setOpenMobile}>\n          <SheetContent\n            side={side}\n            showCloseButton={false}\n            aria-describedby={undefined}\n            style={{ \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE } as CSSProperties}\n            className=\"w-(--sidebar-width) max-w-5/6 gap-0 overflow-hidden p-0\" // check-tokens-ignore: the sidebar's own width, set just here\n          >\n            <SheetTitle className=\"sr-only\">{label}</SheetTitle>\n            <nav\n              ref={setRef}\n              id={sidebar.id}\n              data-slot=\"sidebar\"\n              data-mobile=\"true\"\n              data-side={side}\n              aria-label={label}\n              className={cn(\"flex h-full w-full flex-col text-foreground\", className)}\n              {...props}\n            >\n              {children}\n            </nav>\n          </SheetContent>\n        </Sheet>\n      </PanelContext.Provider>\n    );\n  }\n\n  return (\n    <PanelContext.Provider value={panel}>\n      <nav\n        ref={setRef}\n        id={sidebar.id}\n        data-slot=\"sidebar\"\n        data-state={sidebar.state}\n        data-collapsible={collapsed ? collapsible : \"\"}\n        data-side={side}\n        aria-label={label}\n        // Slid away, nothing in it can be reached\n        inert={collapsible === \"offcanvas\" && collapsed ? true : undefined}\n        className={cn(\n          // In the flow and sticky, so the content beside it makes room; phones get the sheet instead\n          \"group peer sticky top-0 hidden h-svh max-h-full shrink-0 text-foreground md:block\",\n          // The width glides on an eased tween that starts and lands softly: never a spring, which would bounce\n          // the page beside it (owner, 2026-10-22: \"we can do much much better\")\n          \"w-(--sidebar-width) data-[collapsible=icon]:w-(--sidebar-width-icon) data-[collapsible=offcanvas]:w-0\", // check-tokens-ignore: the sidebar's own widths, set on SidebarProvider\n          \"transition-all duration-(--duration-slow) ease-in-out\",\n          className,\n        )}\n        {...props}\n      >\n        <div\n          data-slot=\"sidebar-inner\"\n          className={cn(\n            \"relative flex h-full flex-col bg-muted/50\",\n            // Narrowing, the panel follows the width; sliding away, it keeps its width and moves out with the edge\n            collapsible === \"offcanvas\"\n              ? \"w-(--sidebar-width) transition-transform duration-(--duration-slow) ease-in-out\" // check-tokens-ignore: the sidebar's own width\n              : \"w-full\",\n            collapsible === \"offcanvas\" &&\n              (side === \"left\"\n                ? \"group-data-[collapsible=offcanvas]:-translate-x-full\"\n                : \"group-data-[collapsible=offcanvas]:translate-x-full\"),\n          )}\n        >\n          {children}\n        </div>\n      </nav>\n    </PanelContext.Provider>\n  );\n}\n\n/** Opens and closes the sidebar (the sheet, on phones). Also accepts every Button prop. */\nfunction SidebarTrigger({ className, onClick, children, ...props }: ComponentProps<typeof Button>) {\n  const sidebar = useSidebar();\n  const expanded = sidebar.isMobile ? sidebar.openMobile : sidebar.open;\n  const key = sidebar.shortcut ? sidebar.shortcut.toUpperCase() : null;\n  return (\n    <Button\n      data-slot=\"sidebar-trigger\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      aria-label=\"Toggle sidebar\"\n      aria-expanded={expanded}\n      // The sheet's sidebar exists only while it's open\n      aria-controls={sidebar.isMobile && !sidebar.openMobile ? undefined : sidebar.id}\n      aria-keyshortcuts={key ? `Meta+${key} Control+${key}` : undefined}\n      className={className}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) sidebar.toggleSidebar();\n      }}\n      {...props}\n    >\n      {children ?? <PanelIcon open={expanded} />}\n    </Button>\n  );\n}\n\n/**\n * The trigger's panel icon, which shows the sidebar's state: its divider slides in as the sidebar narrows and\n * back out as it opens (an icon that represents a state, on spring.snappy). Nothing moves on first paint.\n */\nfunction PanelIcon({ open }: { open: boolean }) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n      <motion.path\n        d=\"M9 3v18\"\n        initial={false}\n        animate={{ x: open ? 0 : -2.5 }}\n        transition={reduce ? { duration: 0 } : spring.snappy}\n      />\n    </svg>\n  );\n}\n\n/**\n * A strip along the sidebar's edge that toggles it on click, for the mouse. The trigger is the way for the\n * keyboard and screen readers, so the rail is hidden from both.\n */\nfunction SidebarRail({ className, onClick, ...props }: ComponentProps<\"button\">) {\n  const sidebar = useSidebar();\n  const panel = useContext(PanelContext);\n  if (sidebar.isMobile || !panel || panel.collapsible === \"none\") return null;\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sidebar-rail\"\n      aria-hidden\n      tabIndex={-1}\n      title=\"Toggle sidebar\"\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) sidebar.toggleSidebar();\n      }}\n      className={cn(\n        \"absolute inset-y-0 z-20 w-4 outline-hidden\",\n        panel.side === \"left\"\n          ? \"-right-2 cursor-w-resize group-data-[state=collapsed]:cursor-e-resize\"\n          : \"-left-2 cursor-e-resize group-data-[state=collapsed]:cursor-w-resize\",\n        // The edge's line shows while the pointer is on it\n        \"after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:bg-border after:opacity-0 after:transition-opacity after:duration-(--duration-fast) after:ease-out hover:after:opacity-100\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** The main content beside the sidebar: a `main` landmark (asChild renders your own element instead) */\nfunction SidebarInset({\n  asChild = false,\n  className,\n  ...props\n}: ComponentProps<\"main\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot.Root : \"main\";\n  return (\n    <Comp\n      data-slot=\"sidebar-inset\"\n      className={cn(\"relative flex w-full min-w-0 flex-1 flex-col bg-background\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarHeader({ className, ...props }: ComponentProps<\"div\">) {\n  return <div data-slot=\"sidebar-header\" className={cn(\"flex flex-col gap-2 p-2\", className)} {...props} />;\n}\n\nfunction SidebarFooter({ className, ...props }: ComponentProps<\"div\">) {\n  return <div data-slot=\"sidebar-footer\" className={cn(\"flex flex-col gap-2 p-2\", className)} {...props} />;\n}\n\n/** A quiet line between groups. Decorative, so screen readers skip it (the groups have their own labels). */\nfunction SidebarSeparator({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"sidebar-separator\"\n      className={cn(\"mx-2 h-px w-auto shrink-0 bg-border\", className)}\n      {...props}\n    />\n  );\n}\n\ntype Box = { x: number; y: number; width: number; height: number };\n\n/** Where a row sits in the content, from layout offsets: they ignore transforms (the sheet sliding in), zoom and scrolling */\nfunction boxOf(row: HTMLElement, content: HTMLElement): Box {\n  let x = 0;\n  let y = 0;\n  let node: HTMLElement | null = row;\n  while (node && node !== content) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n    node = node.offsetParent as HTMLElement | null;\n  }\n  if (node !== content) {\n    // The content isn't one of the row's positioned ancestors: fall back to screen positions\n    const a = row.getBoundingClientRect();\n    const b = content.getBoundingClientRect();\n    x = a.left - b.left + content.scrollLeft;\n    y = a.top - b.top + content.scrollTop;\n  }\n  return { x, y, width: row.offsetWidth, height: row.offsetHeight };\n}\n\n/**\n * One highlight behind the rows: its place and size as motion values, so moving it never re-renders anything.\n * It writes its own style the moment a value changes, rather than through a motion component, which paints\n * on the next frame: a ResizeObserver runs after this frame's animation callbacks, so while the width glides\n * the highlight would trail its row by a frame.\n */\nfunction createHighlight() {\n  const x = motionValue(0);\n  const y = motionValue(0);\n  const width = motionValue(0);\n  const height = motionValue(0);\n  const scaleX = motionValue(1);\n  const scaleY = motionValue(1);\n  const opacity = motionValue(0);\n  const values = [x, y, width, height, scaleX, scaleY, opacity];\n  let row: HTMLElement | null = null;\n  let fade: AnimationPlaybackControls | null = null;\n\n  // jump(), not set(): set() would count the move as speed for the next glide's spring (motion.md)\n  function jumpTo(box: Box) {\n    x.jump(box.x);\n    y.jump(box.y);\n    width.jump(box.width);\n    height.jump(box.height);\n    scaleX.jump(1);\n    scaleY.jump(1);\n  }\n\n  // A change of size glides as a scale: the new size at once, scaled to look like the old, then back to 1\n  function glideTo(box: Box) {\n    if (box.width > 0 && box.height > 0 && (box.width !== width.get() || box.height !== height.get())) {\n      const shown = { width: width.get() * scaleX.get(), height: height.get() * scaleY.get() };\n      width.jump(box.width);\n      height.jump(box.height);\n      scaleX.jump(shown.width / box.width);\n      scaleY.jump(shown.height / box.height);\n    }\n    animate(x, box.x, spring.snappy);\n    animate(y, box.y, spring.snappy);\n    animate(scaleX, 1, spring.snappy);\n    animate(scaleY, 1, spring.snappy);\n  }\n\n  function fadeTo(value: number, instant: boolean) {\n    fade?.stop();\n    fade = null;\n    if (instant) opacity.jump(value);\n    else fade = animate(opacity, value, { duration: duration.fast, ease: value ? ease.out : ease.in });\n  }\n\n  return {\n    /** Draws onto the element from now on; returns the way to stop */\n    attach(element: HTMLElement) {\n      const paint = () => {\n        element.style.transform = `translate(${x.get()}px, ${y.get()}px) scale(${scaleX.get()}, ${scaleY.get()})`;\n        element.style.width = `${width.get()}px`;\n        element.style.height = `${height.get()}px`;\n        element.style.opacity = String(opacity.get());\n      };\n      paint();\n      const unsubscribe = values.map((value) => value.on(\"change\", paint));\n      return () => {\n        for (const stop of unsubscribe) stop();\n      };\n    },\n    row: () => row,\n    /**\n     * Onto a row, or away (null). Animated, it glides there while it's showing (even half faded), or fades in\n     * there if it isn't; it fades out where it was.\n     */\n    moveTo(next: HTMLElement | null, content: HTMLElement, animated: boolean) {\n      row = next;\n      if (!next) {\n        fadeTo(0, !animated);\n        return;\n      }\n      const box = boxOf(next, content);\n      if (animated && opacity.get() > 0) glideTo(box);\n      else jumpTo(box);\n      fadeTo(1, !animated);\n    },\n    /** The layout moved under it: keep to its row at once, or carry a glide on to the row's new place */\n    follow(content: HTMLElement) {\n      if (!row?.isConnected) return;\n      const box = boxOf(row, content);\n      if (x.isAnimating() || y.isAnimating() || scaleX.isAnimating() || scaleY.isAnimating()) glideTo(box);\n      else jumpTo(box);\n    },\n    stop() {\n      for (const value of values) value.stop();\n    },\n  };\n}\n\ntype Highlight = ReturnType<typeof createHighlight>;\n\nconst CURRENT_ROWS =\n  \"[data-slot=sidebar-menu-button][data-active=true], [data-slot=sidebar-menu-sub-button][data-active=true]\";\n\n/**\n * The row the current-page highlight belongs on: the last row marked active (a sub-page comes after its\n * section). Inside a sub-menu that isn't showing (its section is closed, or the sidebar is down to icons),\n * the row it hangs from stands in.\n */\nfunction currentRow(content: HTMLElement, iconMode: boolean) {\n  const marked = content.querySelectorAll<HTMLElement>(CURRENT_ROWS);\n  let row: HTMLElement | null = marked[marked.length - 1] ?? null;\n  while (row) {\n    // Typed by hand: `row` changes inside the loop, which makes their inferred types circular\n    const sub: HTMLElement | null = row.closest<HTMLElement>(\"[data-slot=sidebar-menu-sub]\");\n    if (!sub || !content.contains(sub)) break;\n    const section = sub.closest<HTMLElement>(\"[data-slot=sidebar-menu-collapsible-content]\");\n    if (!iconMode && section?.dataset.state !== \"closed\") break;\n    const item: HTMLLIElement | null | undefined = sub.parentElement?.closest(\"li\");\n    row = item?.querySelector<HTMLElement>(\":scope > [data-slot=sidebar-menu-button]\") ?? null;\n  }\n  return row;\n}\n\nfunction HighlightLayer({ highlight, className }: { highlight: Highlight; className: string }) {\n  const node = useRef<HTMLSpanElement>(null);\n  useLayoutEffect(() => (node.current ? highlight.attach(node.current) : undefined), [highlight]);\n  return (\n    // Hidden until it's placed (in the server HTML, each row draws its own background meanwhile)\n    <span\n      ref={node}\n      aria-hidden\n      data-slot=\"sidebar-highlight\"\n      className={cn(\n        \"pointer-events-none absolute top-0 left-0 -z-10 origin-top-left rounded-md opacity-0\",\n        className,\n      )}\n    />\n  );\n}\n\n/** The scrolling middle, holding the groups. The highlights behind its rows live here. */\nfunction SidebarContent({ className, style, children, ref, ...props }: ComponentProps<\"div\">) {\n  const panel = useContext(PanelContext);\n  const iconMode = panel?.iconMode ?? false;\n  const reduce = useReducedMotion() ?? false;\n  const node = useRef<HTMLDivElement>(null);\n  const setRef = useMergedRef(node, ref);\n  const [version, invalidate] = useReducer((count: number) => count + 1, 0);\n  const [ready, setReady] = useState(false);\n  const [current] = useState(createHighlight);\n  const [hover] = useState(createHighlight);\n  const placed = useRef(false);\n\n  // The current page's row. The first time, the highlight is simply there; after that it glides.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `version` counts the changes rows report\n  useLayoutEffect(() => {\n    const content = node.current;\n    if (!content) return;\n    current.moveTo(currentRow(content, iconMode), content, placed.current && !reduce);\n    placed.current = true;\n    setReady(true);\n  }, [version, iconMode, reduce, current]);\n\n  // Rows move when a section opens or the width glides: the highlights keep to theirs, frame by frame\n  // biome-ignore lint/correctness/useExhaustiveDependencies: rows may have come or gone (`version`)\n  useLayoutEffect(() => {\n    const content = node.current;\n    if (!content) return;\n    const follow = () => {\n      current.follow(content);\n      hover.follow(content);\n    };\n    const observer = new ResizeObserver(follow);\n    observer.observe(content);\n    for (const child of Array.from(content.children)) {\n      if (child.getAttribute(\"data-slot\") !== \"sidebar-highlight\") observer.observe(child);\n    }\n    return () => observer.disconnect();\n  }, [version, current, hover]);\n\n  useEffect(\n    () => () => {\n      current.stop();\n      hover.stop();\n    },\n    [current, hover],\n  );\n\n  const glide = useMemo(\n    () => ({\n      ready,\n      invalidate,\n      enter: (row: HTMLElement) => {\n        if (node.current) hover.moveTo(row, node.current, !reduce);\n      },\n      leave: (row: HTMLElement) => {\n        if (node.current && hover.row() === row) hover.moveTo(null, node.current, !reduce);\n      },\n    }),\n    [ready, reduce, hover],\n  );\n\n  return (\n    <GlideContext.Provider value={glide}>\n      <div\n        ref={setRef}\n        data-slot=\"sidebar-content\"\n        className={cn(\n          // `isolate`: the highlights pass behind every row's text (rows aren't stacking contexts)\n          \"relative isolate flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-y-hidden\",\n          className,\n        )}\n        // A long list still scrolls (wheel, trackpad, touch, keyboard), without a scrollbar in the panel (owner,\n        // 2026-10-22)\n        style={{ scrollbarWidth: \"none\", ...style }}\n        {...props}\n      >\n        {/* The hover highlight under the current one, so the current row keeps its colour while hovered */}\n        <HighlightLayer highlight={hover} className=\"bg-accent/60\" />\n        <HighlightLayer highlight={current} className=\"bg-accent\" />\n        {children}\n      </div>\n    </GlideContext.Provider>\n  );\n}\n\nfunction SidebarGroup({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      className={cn(\n        \"relative flex w-full min-w-0 flex-col p-2\",\n        // Down to icons, a short line where the group's label was, so the groups still read as groups. A\n        // line, so it only fades, a little after the label has gone.\n        \"before:pointer-events-none before:absolute before:inset-x-0 before:top-4 before:mx-auto before:h-px before:w-4 before:bg-border before:opacity-0\",\n        \"before:transition-opacity before:duration-(--duration-fast) before:ease-out\",\n        \"group-data-[collapsible=icon]:has-data-[slot=sidebar-group-label]:before:opacity-100 group-data-[collapsible=icon]:before:delay-(--duration-fast)\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * A group's heading. Down to icons it leaves through the blur as it shrinks to half its height, alongside the\n * width, so the icons close up into one column instead of leaving a gap where each label was (owner,\n * 2026-10-22); the group draws a short line there instead.\n */\nfunction SidebarGroupLabel({\n  asChild = false,\n  className,\n  ...props\n}: ComponentProps<\"div\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot.Root : \"div\";\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      className={cn(\n        \"flex h-8 shrink-0 items-center gap-2 overflow-hidden rounded-md px-2 text-xs font-medium whitespace-nowrap text-muted-foreground\",\n        \"transition-all duration-(--duration-slow) ease-in-out group-data-[collapsible=icon]:h-4\",\n        \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        \"[&>svg]:size-(--size-icon-md) [&>svg]:shrink-0 group-data-[collapsible=icon]:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupContent({ className, ...props }: ComponentProps<\"div\">) {\n  return <div data-slot=\"sidebar-group-content\" className={cn(\"w-full text-sm\", className)} {...props} />;\n}\n\nfunction SidebarMenu({ className, ...props }: ComponentProps<\"ul\">) {\n  return (\n    <ul data-slot=\"sidebar-menu\" className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)} {...props} />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: ComponentProps<\"li\">) {\n  return (\n    <li data-slot=\"sidebar-menu-item\" className={cn(\"group/menu-item relative\", className)} {...props} />\n  );\n}\n\n/** A row tells the highlight when it becomes or stops being current, and when it comes or goes while current */\nfunction useCurrentRow(isActive: boolean, invalidate: (() => void) | undefined) {\n  useLayoutEffect(() => {\n    if (!isActive || !invalidate) return;\n    invalidate();\n    return invalidate;\n  }, [isActive, invalidate]);\n}\n\n/** On phones, following a link in the sheet closes it (a plain click, not one that opens a new tab) */\nfunction closeOnNavigate(event: MouseEvent<HTMLElement>, sidebar: SidebarContextValue) {\n  if (!sidebar.isMobile || !(event.currentTarget instanceof HTMLAnchorElement)) return;\n  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;\n  sidebar.setOpenMobile(false);\n}\n\nconst rowFocus =\n  \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\";\nconst rowDisabled =\n  \"disabled:pointer-events-none disabled:opacity-(--opacity-disabled) aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)\";\n\n/** Hover and current-page backgrounds a row draws itself, where no gliding highlight does (header, footer, first paint) */\nconst ownHighlight = \"hover:bg-accent/60 data-[active=true]:bg-accent\";\n\nconst sidebarMenuButtonVariants = cva(\n  [\n    \"peer/menu-button relative flex w-full cursor-pointer items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm whitespace-nowrap text-muted-foreground\",\n    \"transition-colors duration-(--duration-fast) ease-out hover:text-foreground data-[active=true]:font-medium data-[active=true]:text-foreground\",\n    rowFocus,\n    rowDisabled,\n    \"[&>span:last-child]:truncate [&>svg]:size-(--size-icon-md) [&>svg]:shrink-0\",\n    // Down to icons, all but the icon has left through the blur (Sidebar plays it). It stays clipped and\n    // invisible, and still names the row for screen readers.\n    \"group-data-[collapsible=icon]:[&>:not(:first-child)]:opacity-0\",\n    // A badge beside the row keeps the label clear of it\n    \"group-has-data-[slot=sidebar-menu-badge]/menu-item:pe-8\",\n  ],\n  {\n    variants: {\n      size: {\n        sm: \"h-7 text-xs\",\n        md: \"h-8\",\n        // The picture never shrinks to fit (a squeezed logo turned into a pill, owner 2026-10-22). Down to icons,\n        // the 32px picture moves 8px towards the edge to sit centred and scales to 24px, so the\n        // rail's pictures weigh what its icons do (owner, 2026-10-22): transforms, alongside the width\n        lg: \"h-12 [&>:first-child]:shrink-0 [&>:first-child]:transition-transform [&>:first-child]:duration-(--duration-slow) [&>:first-child]:ease-in-out group-data-[collapsible=icon]:[&>:first-child]:-translate-x-2 group-data-[collapsible=icon]:[&>:first-child]:scale-75\",\n      },\n    },\n    defaultVariants: { size: \"md\" },\n  },\n);\n\nexport type SidebarMenuButtonProps = ComponentProps<\"button\"> &\n  VariantProps<typeof sidebarMenuButtonVariants> & {\n    /** Render the single child (for example a link) as the row */\n    asChild?: boolean;\n    /** The current page: the highlight sits behind it, and it gets aria-current=\"page\" */\n    isActive?: boolean;\n    /** Shown beside the row while the sidebar is down to its icons: the label, or TooltipContent props */\n    tooltip?: string | ComponentProps<typeof TooltipContent>;\n  };\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  size = \"md\",\n  tooltip,\n  className,\n  onClick,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: SidebarMenuButtonProps) {\n  const sidebar = useSidebar();\n  const panel = useContext(PanelContext);\n  const glide = useContext(GlideContext);\n  useCurrentRow(isActive, glide?.invalidate);\n  const Comp = asChild ? Slot.Root : \"button\";\n\n  const button = (\n    <Comp\n      type={asChild ? undefined : \"button\"}\n      data-slot=\"sidebar-menu-button\"\n      data-size={size}\n      data-active={isActive}\n      aria-current={isActive ? \"page\" : undefined}\n      className={cn(\n        sidebarMenuButtonVariants({ size }),\n        // In SidebarContent the gliding highlights take over once they're placed\n        !glide?.ready && ownHighlight,\n        // Down to icons, a tall row's picture is the target on its own\n        !glide?.ready &&\n          size === \"lg\" &&\n          \"group-data-[collapsible=icon]:hover:bg-transparent group-data-[collapsible=icon]:data-[active=true]:bg-transparent\",\n        className,\n      )}\n      onClick={(event: MouseEvent<HTMLButtonElement>) => {\n        onClick?.(event);\n        closeOnNavigate(event, sidebar);\n      }}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (event.pointerType === \"mouse\") glide?.enter(event.currentTarget);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        glide?.leave(event.currentTarget);\n      }}\n      {...props}\n    />\n  );\n\n  if (!tooltip) return button;\n  const content = typeof tooltip === \"string\" ? { children: tooltip } : tooltip;\n  const named = panel?.iconMode ?? false;\n  // A label only goes missing once the sidebar is down to its icons; until then the tooltip never opens\n  const hold = (event: SyntheticEvent) => {\n    if (!named) event.preventDefault();\n  };\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild onPointerMove={hold} onFocus={hold}>\n        {button}\n      </TooltipTrigger>\n      {named ? <TooltipContent side={panel?.side === \"right\" ? \"left\" : \"right\"} {...content} /> : null}\n    </Tooltip>\n  );\n}\n\n/**\n * A count or status beside a row, at its end. Down to icons it leaves with the labels and a dot takes its\n * place on the icon's corner, so the rail still says something's waiting there.\n */\nfunction SidebarMenuBadge({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <>\n      <div\n        data-slot=\"sidebar-menu-badge\"\n        className={cn(\n          \"pointer-events-none absolute end-1 top-1.5 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-muted-foreground tabular-nums select-none\",\n          \"transition-colors duration-(--duration-fast) ease-out peer-hover/menu-button:text-foreground peer-data-[active=true]/menu-button:text-foreground\",\n          \"peer-data-[size=sm]/menu-button:top-1 peer-data-[size=lg]/menu-button:top-3.5\",\n          \"group-data-[collapsible=icon]:opacity-0\",\n          className,\n        )}\n        {...props}\n      />\n      {/* The dot grows in once the badge has gone, and goes first on the way back (a fill, so no blur) */}\n      <span\n        aria-hidden\n        data-slot=\"sidebar-menu-badge-dot\"\n        className={cn(\n          // Touching the icon's top-right corner from outside, in the rows' quiet colour, so it reads as a note\n          // rather than a hole in the icon (owner, 2026-10-22)\n          \"pointer-events-none absolute start-6 top-1 size-1.5 scale-50 rounded-full bg-muted-foreground opacity-0\",\n          \"transition duration-(--duration-base) ease-out\",\n          \"peer-data-[size=sm]/menu-button:top-0.5\",\n          \"group-data-[collapsible=icon]:scale-100 group-data-[collapsible=icon]:opacity-100 group-data-[collapsible=icon]:delay-(--duration-fast)\",\n        )}\n      />\n    </>\n  );\n}\n\nexport type SidebarMenuCollapsibleProps = ComponentProps<\"li\"> & {\n  /** Whether the section is open, when you control it */\n  open?: boolean;\n  /** Whether it starts open */\n  defaultOpen?: boolean;\n  /** Called when it opens or closes */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n};\n\n/**\n * A menu item whose row opens a sub-menu, built on Radix Collapsible. Down to icons it folds away and\n * remembers whether it was open.\n */\nfunction SidebarMenuCollapsible({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled,\n  className,\n  ...props\n}: SidebarMenuCollapsibleProps) {\n  const panel = useContext(PanelContext);\n  const glide = useContext(GlideContext);\n  const [own, setOwn] = useState(defaultOpen);\n  const open = openProp ?? own;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      setOwn(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n  // There's no room for a sub-menu beside the icons: it folds, and opens again with the sidebar\n  const shown = open && !panel?.iconMode;\n  const invalidate = glide?.invalidate;\n  // The current page may be inside: the highlight moves between the sub-page and this row\n  // biome-ignore lint/correctness/useExhaustiveDependencies: folding or unfolding is what's reported\n  useLayoutEffect(() => {\n    invalidate?.();\n  }, [shown, invalidate]);\n\n  return (\n    <SectionContext.Provider value={{ open, shown, setOpen }}>\n      <CollapsiblePrimitive.Root asChild open={shown} onOpenChange={setOpen} disabled={disabled}>\n        <li\n          data-slot=\"sidebar-menu-collapsible\"\n          className={cn(\"group/menu-item relative\", className)}\n          {...props}\n        />\n      </CollapsiblePrimitive.Root>\n    </SectionContext.Provider>\n  );\n}\n\n/** The section's row, with a chevron that turns as it opens. Also accepts every SidebarMenuButton prop. */\nfunction SidebarMenuCollapsibleTrigger({ children, className, onClick, ...props }: SidebarMenuButtonProps) {\n  const sidebar = useSidebar();\n  const panel = useContext(PanelContext);\n  const section = useSection(\"SidebarMenuCollapsibleTrigger\");\n  return (\n    <CollapsiblePrimitive.Trigger\n      asChild\n      onClick={(event) => {\n        onClick?.(event);\n        // Down to icons, the row opens the sidebar with its section showing, rather than a section no one can see\n        if (!event.defaultPrevented && panel?.iconMode) {\n          event.preventDefault();\n          section.setOpen(true);\n          sidebar.setOpen(true);\n        }\n      }}\n    >\n      <SidebarMenuButton className={cn(\"[&>span:nth-last-child(2)]:truncate\", className)} {...props}>\n        {children}\n        {/* CSS turns it, so it doesn't move on first paint and is instant with reduced motion */}\n        <span\n          aria-hidden\n          data-slot=\"sidebar-menu-chevron\"\n          className={cn(\n            \"ms-auto flex shrink-0 transition-transform duration-(--duration-base) ease-out\",\n            section.shown && \"rotate-90\",\n          )}\n        >\n          <ChevronRightIcon className=\"size-(--size-icon-md)\" />\n        </span>\n      </SidebarMenuButton>\n    </CollapsiblePrimitive.Trigger>\n  );\n}\n\n/**\n * The section's sub-menu, opening and closing on the shipped collapsible keyframes (Accordion's height\n * pattern). It stays in the HTML while closed (hidden), so the highlight can tell a current page inside it.\n */\nfunction SidebarMenuCollapsibleContent({\n  className,\n  children,\n  onAnimationEnd,\n  ref,\n  ...props\n}: ComponentProps<typeof CollapsiblePrimitive.Content>) {\n  const section = useSection(\"SidebarMenuCollapsibleContent\");\n  const node = useRef<HTMLDivElement>(null);\n  const setRef = useMergedRef(node, ref);\n  // At rest once its keyframes end: hidden when closed, unclipped when open (focus rings inside aren't cut).\n  // Reset in the same render as it opens or closes, so it's clipped from the first frame.\n  const [settled, setSettled] = useState(true);\n  const [was, setWas] = useState(section.shown);\n  if (section.shown !== was) {\n    setWas(section.shown);\n    setSettled(false);\n  }\n\n  // Radix starts the keyframes after measuring; they glide to and from the content's own height. Radix's\n  // --radix-collapsible-content-height lags a render behind while the content stays mounted, so set ours.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: measured each time it opens or closes\n  useLayoutEffect(() => {\n    const element = node.current;\n    if (!element) return;\n    element.style.setProperty(\"--collapsible-height\", `${element.scrollHeight}px`);\n    // Without the keyframes (the tokens aren't installed) no animationend comes: settle at once, as Radix does\n    if (getComputedStyle(element).animationName === \"none\") setSettled(true);\n  }, [section.shown]);\n\n  return (\n    <CollapsiblePrimitive.Content\n      ref={setRef}\n      forceMount\n      data-slot=\"sidebar-menu-collapsible-content\"\n      hidden={!section.shown && settled}\n      className={cn(\n        \"data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close\",\n        !(section.shown && settled) && \"overflow-hidden\",\n        className,\n      )}\n      onAnimationEnd={(event) => {\n        if (event.target === event.currentTarget) setSettled(true);\n        onAnimationEnd?.(event);\n      }}\n      {...props}\n    >\n      {children}\n    </CollapsiblePrimitive.Content>\n  );\n}\n\n/** Rows under a row, along a quiet line. Down to icons it's hidden (in a SidebarMenuCollapsible it folds instead). */\nfunction SidebarMenuSub({ className, ...props }: ComponentProps<\"ul\">) {\n  const section = useContext(SectionContext);\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      className={cn(\n        \"ms-4 flex min-w-0 flex-col gap-1 border-s border-border px-2.5 py-0.5\",\n        !section && \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({ className, ...props }: ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      className={cn(\"group/menu-sub-item relative\", className)}\n      {...props}\n    />\n  );\n}\n\nconst sidebarMenuSubButtonVariants = cva(\n  [\n    \"relative flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md px-2 whitespace-nowrap text-muted-foreground\",\n    \"transition-colors duration-(--duration-fast) ease-out hover:text-foreground data-[active=true]:font-medium data-[active=true]:text-foreground\",\n    rowFocus,\n    rowDisabled,\n    \"[&>span:last-child]:truncate [&>svg]:size-(--size-icon-md) [&>svg]:shrink-0\",\n  ],\n  {\n    variants: { size: { sm: \"text-xs\", md: \"text-sm\" } },\n    defaultVariants: { size: \"md\" },\n  },\n);\n\nexport type SidebarMenuSubButtonProps = ComponentProps<\"a\"> &\n  VariantProps<typeof sidebarMenuSubButtonVariants> & {\n    /** Render the single child (for example a Next.js Link or a button) as the row */\n    asChild?: boolean;\n    /** The current page */\n    isActive?: boolean;\n  };\n\n/** A sub-menu row: a link by default, as in shadcn */\nfunction SidebarMenuSubButton({\n  asChild = false,\n  isActive = false,\n  size = \"md\",\n  className,\n  onClick,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: SidebarMenuSubButtonProps) {\n  const sidebar = useSidebar();\n  const glide = useContext(GlideContext);\n  useCurrentRow(isActive, glide?.invalidate);\n  const Comp = asChild ? Slot.Root : \"a\";\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      aria-current={isActive ? \"page\" : undefined}\n      className={cn(sidebarMenuSubButtonVariants({ size }), !glide?.ready && ownHighlight, className)}\n      onClick={(event: MouseEvent<HTMLAnchorElement>) => {\n        onClick?.(event);\n        closeOnNavigate(event, sidebar);\n      }}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (event.pointerType === \"mouse\") glide?.enter(event.currentTarget);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        glide?.leave(event.currentTarget);\n      }}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuCollapsible,\n  SidebarMenuCollapsibleContent,\n  SidebarMenuCollapsibleTrigger,\n  SidebarMenuItem,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n};\n"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/sidebar"
}
