astralyx-ui 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # astralyx-ui
2
2
 
3
- **309 accessible React components you copy into your repo** — with a CLI and a
3
+ **343 accessible React components you copy into your repo** — with a CLI and a
4
4
  registry that work out what each one needs and bring that too.
5
5
 
6
6
  Not a dependency. There is no package to upgrade and nothing to fight when a
@@ -14,7 +14,7 @@ npx astralyx-ui add button
14
14
 
15
15
  ## What you get
16
16
 
17
- - **309 components** across 33 categories — forms, data, overlays, commerce,
17
+ - **343 components** across 36 categories — forms, data, overlays, commerce,
18
18
  auth, AI, crypto, observability, developer tooling.
19
19
  - **12 primitives** underneath them. Slot, Popper, FocusTrap, Dismissable and
20
20
  friends — no headless-UI dependency.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
- "description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
5
+ "description": "343 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
7
7
  "author": "Astralyx",
8
8
  "homepage": "https://ui.astralyx.dev",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -3977,7 +3977,7 @@
3977
3977
  "name": "sidebar",
3978
3978
  "type": "registry:ui",
3979
3979
  "title": "Sidebar",
3980
- "description": "An inset application frame: a transparent rail that collapses to a 52px icon strip, beside a rounded content panel. Inset is the only layout and icon is the only collapsed state, which is what keeps the geometry exact.",
3980
+ "description": "Two sidebars from one set of parts: the app frame — a transparent rail that collapses to a 52px icon strip beside a rounded content panel — and a static nav that lives inside a page. Inset is the only frame layout and icon the only collapsed state, which is what keeps the geometry exact.",
3981
3981
  "category": "Navigation",
3982
3982
  "dependencies": [
3983
3983
  "lucide-react@^1.39.0"
@@ -2,7 +2,7 @@
2
2
  "name": "sidebar",
3
3
  "type": "registry:ui",
4
4
  "title": "Sidebar",
5
- "description": "An inset application frame: a transparent rail that collapses to a 52px icon strip, beside a rounded content panel. Inset is the only layout and icon is the only collapsed state, which is what keeps the geometry exact.",
5
+ "description": "Two sidebars from one set of parts: the app frame — a transparent rail that collapses to a 52px icon strip beside a rounded content panel — and a static nav that lives inside a page. Inset is the only frame layout and icon the only collapsed state, which is what keeps the geometry exact.",
6
6
  "category": "Navigation",
7
7
  "dependencies": [
8
8
  "lucide-react@^1.39.0"
@@ -18,7 +18,7 @@
18
18
  {
19
19
  "path": "components/ui/sidebar.tsx",
20
20
  "type": "registry:ui",
21
- "content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useId,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { PanelLeft } from 'lucide-react'\nimport { Slot } from '@/components/primitives/slot'\nimport { useBreakpoint } from '@/components/primitives/media-query'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport {\n disabledState,\n focusRing,\n iconChild,\n interactive,\n radius,\n sidebarInk,\n sidebarSurface,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An inset application frame: a transparent rail beside a rounded content\n * panel, both floating on the page background.\n *\n * Inset is the only layout, and icon is the only collapsed state. Those are\n * decisions, not omissions — an off-canvas sidebar needs an overlay, a scrim\n * and focus containment, and a flush sidebar needs a border that the inset\n * panel already provides by being a separate surface. Supporting one shape\n * well is what keeps the geometry exact.\n *\n * The grid, in one place:\n *\n * gutter 8px `p-2` on the frame, `gap-2` between rail and panel, and\n * `px-2` inside the rail — one value everywhere\n * rail 52px 36 + 2×8: one control unit plus that same gutter either\n * side. The gutter is not decoration — a menu row's focus\n * ring is a 3px box-shadow, and `SidebarContent` scrolls,\n * so a rail sized to the button exactly would clip the ring\n * against its padding box on both edges\n * expanded 256px `w-64`\n * inset 10px `px-2.5` on a menu row — (36 − 16) / 2, which holds a 16px\n * icon on the rail's centre line whether open or collapsed,\n * so nothing shifts sideways as the label appears\n */\nconst RAIL = 'w-13' // 52px — a 36px control unit plus an 8px gutter either side\nconst EXPANDED = 'w-64' // 256px\n\ntype SidebarContext = {\n open: boolean\n setOpen: (open: boolean) => void\n toggle: () => void\n /** Below `md` the rail is forced closed and the trigger is inert. */\n locked: boolean\n id: string\n}\n\nconst SidebarCtx = createContext<SidebarContext | null>(null)\n\nfunction useSidebar() {\n const context = use(SidebarCtx)\n if (!context) {\n throw new Error('useSidebar must be used inside a <SidebarProvider>')\n }\n return context\n}\n\n/**\n * The frame. Owns the open state and paints the page background the rail and\n * panel float on.\n *\n * Narrow viewports pin it collapsed rather than offering an overlay: the rail\n * is 36px, which is already the mobile layout. Nothing to open, nothing to\n * trap focus in, and the content panel keeps the full remaining width.\n */\nfunction SidebarProvider({\n children,\n className,\n open: openProp,\n defaultOpen = true,\n onOpenChange,\n ...props\n}: ComponentProps<'div'> & {\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const wide = useBreakpoint('md')\n const id = useId()\n\n const open = (controlled ? openProp : uncontrolled) && wide\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n [controlled, onOpenChange],\n )\n\n const toggle = useCallback(() => setOpen(!open), [open, setOpen])\n\n // Ctrl/Cmd-B, the conventional shortcut. Bound on the frame rather than the\n // document body would miss it when focus is inside the content panel.\n useEffect(() => {\n if (!wide) return\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'b' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n toggle()\n }\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [toggle, wide])\n\n return (\n <SidebarCtx value={{ open, setOpen, toggle, locked: !wide, id }}>\n <div\n data-slot=\"sidebar-provider\"\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n sidebarSurface,\n 'flex min-h-svh w-full gap-2 p-2',\n className,\n )}\n {...props}\n >\n {children}\n </div>\n </SidebarCtx>\n )\n}\n\n/**\n * The rail itself — transparent by design, so the frame's background reads as\n * one continuous surface behind it and only the content panel looks raised.\n *\n * Width is the one thing in the kit that animates on a size, and only here:\n * collapsing *is* the interaction, not a reaction to a pointer passing over\n * something. Hover and press stay colour-only everywhere, this component\n * included.\n */\nfunction Sidebar({ className, children, ...props }: ComponentProps<'aside'>) {\n const { open, id } = useSidebar()\n\n return (\n <aside\n id={id}\n data-slot=\"sidebar\"\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n 'group/sidebar flex shrink-0 flex-col gap-2 bg-transparent',\n 'transition-[width] duration-200 ease-out motion-reduce:transition-none',\n open ? EXPANDED : RAIL,\n className,\n )}\n {...props}\n >\n {children}\n </aside>\n )\n}\n\n/** Fixed top section — a product mark, a workspace switcher. */\nfunction SidebarHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-header\"\n className={cn('flex min-h-9 shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/**\n * The scrolling middle.\n *\n * Its `px-2` is what keeps a focus ring visible: overflow clips at the padding\n * box, so the gutter has to be padding on the scroller itself, not margin on\n * the rows or width on the rail.\n *\n * Nothing overflows horizontally during the width animation either — a label\n * is `sr-only` the moment the rail collapses, and `truncate` clips it while\n * the rail is still growing.\n */\nfunction SidebarContent({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-content\"\n className={cn(\n 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Fixed bottom section — an account row, a sign-out. */\nfunction SidebarFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-footer\"\n className={cn('flex shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/** A titled run of menu rows. */\nfunction SidebarGroup({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-group\"\n className={cn('flex flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\n/**\n * A section heading. It collapses to nothing rather than to an icon, since a\n * heading has no icon to fall back to — but it keeps its height, so the rows\n * either side of it do not close up as the rail narrows.\n */\nfunction SidebarGroupLabel({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-group-label\"\n className={cn(\n // Same type as DropdownMenuLabel, so a section heading reads the\n // same wherever it appears. Height is on the control grid.\n 'flex h-7 shrink-0 items-center px-2.5 text-xs font-medium',\n sidebarInk.label,\n 'truncate whitespace-nowrap',\n 'group-data-[state=collapsed]/sidebar:invisible',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenu({ className, ...props }: ComponentProps<'ul'>) {\n return (\n <ul\n data-slot=\"sidebar-menu\"\n className={cn('flex list-none flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuItem({ className, ...props }: ComponentProps<'li'>) {\n return <li data-slot=\"sidebar-menu-item\" className={className} {...props} />\n}\n\n/**\n * One row. Renders a button by default, or whatever it is given via `asChild`\n * — a router link, usually.\n *\n * The label is hidden rather than unmounted so the accessible name is stable:\n * a collapsed rail of icon buttons that lose their names to a CSS state is a\n * screen-reader dead end. `sr-only` keeps the text, and the tooltip covers the\n * sighted case.\n */\nfunction SidebarMenuButton({\n className,\n asChild = false,\n isActive = false,\n icon,\n trailing,\n tooltip,\n children,\n ...props\n}: ComponentProps<'button'> & {\n asChild?: boolean\n isActive?: boolean\n icon?: ReactNode\n /** A count or status, hidden with the label when the rail collapses. */\n trailing?: ReactNode\n /** Shown on hover while collapsed. Defaults to the row's own label. */\n tooltip?: ReactNode\n}) {\n const { open } = useSidebar()\n const Comp = asChild ? Slot : 'button'\n\n const row = (\n <Comp\n data-slot=\"sidebar-menu-button\"\n data-active={isActive}\n // `aria-current` is the part a screen reader announces; `data-active`\n // only styles.\n aria-current={isActive ? 'page' : undefined}\n // `px-2.5` is (rail − icon) / 2, so the icon sits on the rail's centre\n // line in both states and the label grows out to its right.\n className={cn(\n 'flex h-9 w-full items-center gap-2 px-2.5 text-sm font-medium',\n radius.control,\n iconChild,\n interactive,\n sidebarInk.ring,\n disabledState,\n // Ink comes from the fixed sidebar token, not the theme, so the row\n // stays legible on the black ground in both themes.\n isActive ? sidebarInk.active : cn(sidebarInk.row, sidebarInk.hover),\n className,\n )}\n {...props}\n >\n {icon}\n <span className=\"min-w-0 flex-1 truncate whitespace-nowrap text-start group-data-[state=collapsed]/sidebar:sr-only\">\n {children}\n </span>\n {trailing && (\n <span className=\"shrink-0 group-data-[state=collapsed]/sidebar:hidden\">\n {trailing}\n </span>\n )}\n </Comp>\n )\n\n if (open) return row\n\n return (\n <Tooltip content={tooltip ?? children} side=\"right\">\n {row}\n </Tooltip>\n )\n}\n\n/** Toggles the rail. Inert below `md`, where the rail is already the layout. */\nfunction SidebarTrigger({\n className,\n onClick,\n ...props\n}: ComponentProps<'button'>) {\n const { open, toggle, locked, id } = useSidebar()\n if (locked) return null\n\n return (\n <button\n type=\"button\"\n data-slot=\"sidebar-trigger\"\n aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}\n aria-expanded={open}\n aria-controls={id}\n onClick={(event) => {\n onClick?.(event)\n toggle()\n }}\n className={cn(\n 'text-muted-foreground hover:bg-secondary hover:text-foreground inline-flex size-9 shrink-0 items-center justify-center',\n radius.control,\n iconChild,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n <PanelLeft />\n </button>\n )\n}\n\n/**\n * The content panel — an ordinary themed surface on the rail's fixed black\n * ground, which is what makes the layout read as inset.\n *\n * The border is not decoration. In the dark theme the panel is `--card` at 13%\n * lightness sitting on an 8% ground: a five-point difference, which is not\n * enough of an edge to see where the page stops and the rail begins. Contrast\n * alone only carries the light theme.\n *\n * `overflow-hidden` is what earns the radius: a table or a list that runs to\n * the panel's edge has to be clipped by the curve, not drawn over it.\n */\nfunction SidebarInset({ className, ...props }: ComponentProps<'main'>) {\n return (\n <main\n data-slot=\"sidebar-inset\"\n className={cn(\n 'bg-card text-card-foreground border-border flex min-w-0 flex-1 flex-col overflow-hidden border',\n radius.surface,\n className,\n )}\n {...props}\n />\n )\n}\n\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInset,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarProvider,\n SidebarTrigger,\n useSidebar,\n}\n"
21
+ "content": "import {\n createContext,\n use,\n useCallback,\n useEffect,\n useId,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { PanelLeft } from 'lucide-react'\nimport { Slot } from '@/components/primitives/slot'\nimport { useBreakpoint } from '@/components/primitives/media-query'\nimport { Tooltip } from '@/components/ui/tooltip'\nimport {\n disabledState,\n focusRing,\n iconChild,\n interactive,\n radius,\n sidebarInk,\n sidebarSurface,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * An inset application frame: a transparent rail beside a rounded content\n * panel, both floating on the page background.\n *\n * Inset is the only layout, and icon is the only collapsed state. Those are\n * decisions, not omissions — an off-canvas sidebar needs an overlay, a scrim\n * and focus containment, and a flush sidebar needs a border that the inset\n * panel already provides by being a separate surface. Supporting one shape\n * well is what keeps the geometry exact.\n *\n * The grid, in one place:\n *\n * gutter 8px `p-2` on the frame, `gap-2` between rail and panel, and\n * `px-2` inside the rail — one value everywhere\n * rail 52px 36 + 2×8: one control unit plus that same gutter either\n * side. The gutter is not decoration — a menu row's focus\n * ring is a 3px box-shadow, and `SidebarContent` scrolls,\n * so a rail sized to the button exactly would clip the ring\n * against its padding box on both edges\n * expanded 256px `w-64`\n * inset 10px `px-2.5` on a menu row — (36 − 16) / 2, which holds a 16px\n * icon on the rail's centre line whether open or collapsed,\n * so nothing shifts sideways as the label appears\n */\nconst RAIL = 'w-13' // 52px — a 36px control unit plus an 8px gutter either side\nconst EXPANDED = 'w-64' // 256px\n\n/**\n * Which of the two sidebars this is.\n *\n * `app` is the product's own frame — the one that owns the window, collapses\n * to a rail, and paints its own fixed ground so the content panel reads as\n * inset. `page` is navigation *within* a page: a settings nav, a docs\n * section, a wizard's steps. It never collapses, has no trigger and no\n * shortcut, takes its height from whatever it sits in, and is painted in the\n * page's own theme rather than on the rail's fixed black.\n *\n * One prop rather than three booleans, because those are not independent: a\n * sidebar that does not own the window has nothing to collapse into and no\n * ground of its own to paint.\n */\ntype SidebarVariant = 'app' | 'page'\n\ntype SidebarContext = {\n open: boolean\n setOpen: (open: boolean) => void\n toggle: () => void\n /** Below `md` the rail is forced closed and the trigger is inert. */\n locked: boolean\n variant: SidebarVariant\n id: string\n}\n\nconst SidebarCtx = createContext<SidebarContext | null>(null)\n\nfunction useSidebar() {\n const context = use(SidebarCtx)\n if (!context) {\n throw new Error('useSidebar must be used inside a <SidebarProvider>')\n }\n return context\n}\n\n/**\n * The frame. Owns the open state and paints the page background the rail and\n * panel float on.\n *\n * Narrow viewports pin it collapsed rather than offering an overlay: the rail\n * is 36px, which is already the mobile layout. Nothing to open, nothing to\n * trap focus in, and the content panel keeps the full remaining width.\n */\nfunction SidebarProvider({\n children,\n className,\n variant = 'app',\n open: openProp,\n defaultOpen = true,\n onOpenChange,\n ...props\n}: ComponentProps<'div'> & {\n variant?: SidebarVariant\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n}) {\n const controlled = openProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(defaultOpen)\n const wide = useBreakpoint('md')\n const id = useId()\n const page = variant === 'page'\n\n // A page sidebar is always open: it has no rail to collapse to, and the\n // breakpoint lock exists so the app frame can shed its width on a phone,\n // which a nav inside a page does by stacking instead.\n const open = page || ((controlled ? openProp : uncontrolled) && wide)\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!controlled) setUncontrolled(next)\n onOpenChange?.(next)\n },\n [controlled, onOpenChange],\n )\n\n const toggle = useCallback(() => setOpen(!open), [open, setOpen])\n\n // Ctrl/Cmd-B, the conventional shortcut. Bound on the frame rather than the\n // document body would miss it when focus is inside the content panel.\n useEffect(() => {\n // Cmd-B belongs to the window's own sidebar. Binding it for a nav inside a\n // page would mean two of them fighting over the same chord on any page\n // that has both.\n if (!wide || page) return\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'b' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n toggle()\n }\n }\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n }, [page, toggle, wide])\n\n return (\n <SidebarCtx value={{ open, setOpen, toggle, locked: page || !wide, variant, id }}>\n <div\n data-slot=\"sidebar-provider\"\n data-variant={variant}\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n 'flex w-full',\n // The app frame owns the window and paints the ground under both\n // columns. A page nav owns nothing: it takes the height it is given\n // and leaves the page's own background alone.\n page\n ? 'min-h-0 flex-col gap-6 md:flex-row md:gap-8'\n : cn(sidebarSurface, 'min-h-svh gap-2 p-2'),\n className,\n )}\n {...props}\n >\n {children}\n </div>\n </SidebarCtx>\n )\n}\n\n/**\n * The rail itself — transparent by design, so the frame's background reads as\n * one continuous surface behind it and only the content panel looks raised.\n *\n * Width is the one thing in the kit that animates on a size, and only here:\n * collapsing *is* the interaction, not a reaction to a pointer passing over\n * something. Hover and press stay colour-only everywhere, this component\n * included.\n */\nfunction Sidebar({ className, children, ...props }: ComponentProps<'aside'>) {\n const { open, variant, id } = useSidebar()\n const page = variant === 'page'\n\n return (\n <aside\n id={id}\n data-slot=\"sidebar\"\n data-variant={variant}\n data-state={open ? 'expanded' : 'collapsed'}\n className={cn(\n 'group/sidebar flex shrink-0 flex-col gap-2 bg-transparent',\n page\n ? // No width transition, because nothing changes it. Full width on a\n // phone so the nav stacks above the content rather than becoming a\n // 224px column beside a squeezed one.\n 'w-full md:w-56'\n : cn(\n 'transition-[width] duration-200 ease-out motion-reduce:transition-none',\n open ? EXPANDED : RAIL,\n ),\n className,\n )}\n {...props}\n >\n {children}\n </aside>\n )\n}\n\n/** Fixed top section — a product mark, a workspace switcher. */\nfunction SidebarHeader({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-header\"\n className={cn('flex min-h-9 shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/**\n * The scrolling middle.\n *\n * Its `px-2` is what keeps a focus ring visible: overflow clips at the padding\n * box, so the gutter has to be padding on the scroller itself, not margin on\n * the rows or width on the rail.\n *\n * Nothing overflows horizontally during the width animation either — a label\n * is `sr-only` the moment the rail collapses, and `truncate` clips it while\n * the rail is still growing.\n */\nfunction SidebarContent({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-content\"\n className={cn(\n 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Fixed bottom section — an account row, a sign-out. */\nfunction SidebarFooter({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-footer\"\n className={cn('flex shrink-0 flex-col gap-2 px-2', className)}\n {...props}\n />\n )\n}\n\n/** A titled run of menu rows. */\nfunction SidebarGroup({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n data-slot=\"sidebar-group\"\n className={cn('flex flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\n/**\n * A section heading. It collapses to nothing rather than to an icon, since a\n * heading has no icon to fall back to — but it keeps its height, so the rows\n * either side of it do not close up as the rail narrows.\n */\nfunction SidebarGroupLabel({ className, ...props }: ComponentProps<'div'>) {\n const { variant } = useSidebar()\n\n return (\n <div\n data-slot=\"sidebar-group-label\"\n className={cn(\n // Same type as DropdownMenuLabel, so a section heading reads the\n // same wherever it appears. Height is on the control grid.\n 'flex h-7 shrink-0 items-center px-2.5 text-xs font-medium',\n // Theme ink on a page, rail ink on the frame — the rail's steps are\n // measured against its own fixed black ground.\n variant === 'page' ? 'text-muted-foreground' : sidebarInk.label,\n 'truncate whitespace-nowrap',\n 'group-data-[state=collapsed]/sidebar:invisible',\n className,\n )}\n {...props}\n />\n )\n}\n\nfunction SidebarMenu({ className, ...props }: ComponentProps<'ul'>) {\n return (\n <ul\n data-slot=\"sidebar-menu\"\n className={cn('flex list-none flex-col gap-1', className)}\n {...props}\n />\n )\n}\n\nfunction SidebarMenuItem({ className, ...props }: ComponentProps<'li'>) {\n return <li data-slot=\"sidebar-menu-item\" className={className} {...props} />\n}\n\n/**\n * One row. Renders a button by default, or whatever it is given via `asChild`\n * — a router link, usually.\n *\n * The label is hidden rather than unmounted so the accessible name is stable:\n * a collapsed rail of icon buttons that lose their names to a CSS state is a\n * screen-reader dead end. `sr-only` keeps the text, and the tooltip covers the\n * sighted case.\n */\nfunction SidebarMenuButton({\n className,\n asChild = false,\n isActive = false,\n icon,\n trailing,\n tooltip,\n children,\n ...props\n}: ComponentProps<'button'> & {\n asChild?: boolean\n isActive?: boolean\n icon?: ReactNode\n /** A count or status, hidden with the label when the rail collapses. */\n trailing?: ReactNode\n /** Shown on hover while collapsed. Defaults to the row's own label. */\n tooltip?: ReactNode\n}) {\n const { open, variant } = useSidebar()\n const page = variant === 'page'\n // `sidebarInk` is measured against the rail's fixed black ground. A page nav\n // sits on the theme's own surface, where those steps are the wrong ones —\n // white-on-white in the light theme.\n const ink = page\n ? {\n row: 'text-muted-foreground',\n hover: 'hover:bg-accent hover:text-foreground',\n active: 'bg-accent text-accent-foreground',\n ring: focusRing,\n }\n : {\n row: sidebarInk.row,\n hover: sidebarInk.hover,\n active: sidebarInk.active,\n ring: sidebarInk.ring,\n }\n const Comp = asChild ? Slot : 'button'\n\n const row = (\n <Comp\n data-slot=\"sidebar-menu-button\"\n data-active={isActive}\n // `aria-current` is the part a screen reader announces; `data-active`\n // only styles.\n aria-current={isActive ? 'page' : undefined}\n // `px-2.5` is (rail − icon) / 2, so the icon sits on the rail's centre\n // line in both states and the label grows out to its right.\n className={cn(\n 'flex h-9 w-full items-center gap-2 px-2.5 text-sm font-medium',\n radius.control,\n iconChild,\n interactive,\n ink.ring,\n disabledState,\n isActive ? ink.active : cn(ink.row, ink.hover),\n className,\n )}\n {...props}\n >\n {icon}\n <span className=\"min-w-0 flex-1 truncate whitespace-nowrap text-start group-data-[state=collapsed]/sidebar:sr-only\">\n {children}\n </span>\n {trailing && (\n <span className=\"shrink-0 group-data-[state=collapsed]/sidebar:hidden\">\n {trailing}\n </span>\n )}\n </Comp>\n )\n\n if (open) return row\n\n // Only the collapsed rail needs one: the label is `sr-only` there, and this\n // is what puts it back for a pointer.\n return (\n <Tooltip content={tooltip ?? children} side=\"right\">\n {row}\n </Tooltip>\n )\n}\n\n/** Toggles the rail. Inert below `md`, where the rail is already the layout. */\nfunction SidebarTrigger({\n className,\n onClick,\n ...props\n}: ComponentProps<'button'>) {\n const { open, toggle, locked, id } = useSidebar()\n if (locked) return null\n\n return (\n <button\n type=\"button\"\n data-slot=\"sidebar-trigger\"\n aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}\n aria-expanded={open}\n aria-controls={id}\n onClick={(event) => {\n onClick?.(event)\n toggle()\n }}\n className={cn(\n 'text-muted-foreground hover:bg-secondary hover:text-foreground inline-flex size-9 shrink-0 items-center justify-center',\n radius.control,\n iconChild,\n interactive,\n focusRing,\n className,\n )}\n {...props}\n >\n <PanelLeft />\n </button>\n )\n}\n\n/**\n * The content panel — an ordinary themed surface on the rail's fixed black\n * ground, which is what makes the layout read as inset.\n *\n * The border is not decoration. In the dark theme the panel is `--card` at 13%\n * lightness sitting on an 8% ground: a five-point difference, which is not\n * enough of an edge to see where the page stops and the rail begins. Contrast\n * alone only carries the light theme.\n *\n * `overflow-hidden` is what earns the radius: a table or a list that runs to\n * the panel's edge has to be clipped by the curve, not drawn over it.\n */\nfunction SidebarInset({ className, ...props }: ComponentProps<'main'>) {\n const { variant } = useSidebar()\n\n return (\n <main\n data-slot=\"sidebar-inset\"\n data-variant={variant}\n className={cn(\n 'flex min-w-0 flex-1 flex-col',\n // A page nav is not a frame, so its content is not a panel: giving it\n // a card surface would put a second raised box inside whatever card or\n // page section already contains it.\n variant === 'page'\n ? 'min-h-0'\n : cn(\n 'bg-card text-card-foreground border-border overflow-hidden border',\n radius.surface,\n ),\n className,\n )}\n {...props}\n />\n )\n}\n\nexport type { SidebarVariant }\nexport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarInset,\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarProvider,\n SidebarTrigger,\n useSidebar,\n}\n"
22
22
  }
23
23
  ]
24
24
  }