Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page. | 1 | import { type VariantProps, cva } from "class-variance-authority"; |
| 2 | import { PanelLeft } from "lucide-react"; | |
| 3 | import { Slot } from "radix-ui"; | |
| 4 | import { type ComponentProps, type CSSProperties, type ReactNode, createContext, useCallback, useContext, useMemo } from "react"; | |
| 5 | ||
| 6 | import { cn } from "../../lib/cn"; | |
| 7 | import { Button } from "./button"; | |
| 8 | import { Hint } from "./hint"; | |
| 9 | import { Input } from "./input"; | |
| 10 | import { Separator } from "./separator"; | |
| 11 | import { Sheet, SheetContent, SheetTitle } from "./sheet"; | |
| 12 | ||
| 13 | // shadcn/ui's sidebar, styled with g1t's tokens: the frame of the rail and | |
| 14 | // the mode sidebar down the left of the app (components/shell.tsx, | |
| 15 | // components/rail.tsx), and of the drawer that stands in for them on a | |
| 16 | // phone. A provider holds whether it is open; `Sidebar` draws the column; | |
| 17 | // header, content and footer stack inside it; groups hold a label and a | |
| 18 | // menu of rows; `SidebarMenuButton` is one row, or one icon of the rail. | |
| 19 | // | |
| 20 | // The colours are the sidebar's own tokens (app.css): `sidebar` for its | |
| 21 | // surface, `sidebar-accent` for the current row, `sidebar-border` for the | |
| 22 | // hairlines, and `rail` for the rail's surface. | |
| 23 | // | |
| 24 | // Unlike shadcn's, the provider does not read the viewport: the page is | |
| 25 | // drawn on the server, so the desktop column and the phone's drawer are | |
| 26 | // both rendered and CSS shows one of them (`hiddenBelow`). Whoever toggles | |
| 27 | // the sidebar decides which one to move (the shell asks `matchMedia`). | |
| 28 | ||
| 29 | export const SIDEBAR_WIDTH = "15rem"; | |
| 30 | export const SIDEBAR_WIDTH_ICON = "3.5rem"; | |
| 31 | ||
| 32 | type SidebarContextValue = { | |
| 33 | /** The desktop column: shown beside the page, or folded away. */ | |
| 34 | open: boolean; | |
| 35 | setOpen: (open: boolean) => void; | |
| 36 | /** The drawer, on a phone or a narrow window. */ | |
| 37 | openMobile: boolean; | |
| 38 | setOpenMobile: (open: boolean) => void; | |
| 39 | /** Shows or hides whichever of the two the window is using. */ | |
| 40 | toggleSidebar: () => void; | |
| 41 | state: "expanded" | "collapsed"; | |
| 42 | }; | |
| 43 | ||
| 44 | const SidebarContext = createContext<SidebarContextValue | null>(null); | |
| 45 | ||
| 46 | export function useSidebar(): SidebarContextValue { | |
| 47 | const context = useContext(SidebarContext); | |
| 48 | if (!context) throw new Error("useSidebar must be used within a SidebarProvider."); | |
| 49 | return context; | |
| 50 | } | |
| 51 | ||
| 52 | export function SidebarProvider({ | |
| 53 | open, | |
| 54 | onOpenChange, | |
| 55 | openMobile, | |
| 56 | onOpenMobileChange, | |
| 57 | toggleSidebar, | |
| 58 | className, | |
| 59 | style, | |
| 60 | children, | |
| 61 | ...props | |
| 62 | }: Omit<ComponentProps<"div">, "children"> & { | |
| 63 | open: boolean; | |
| 64 | onOpenChange: (open: boolean) => void; | |
| 65 | openMobile: boolean; | |
| 66 | onOpenMobileChange: (open: boolean) => void; | |
| 67 | /** Shows or hides the column or the drawer, whichever the window is using. */ | |
| 68 | toggleSidebar: () => void; | |
| 69 | children: ReactNode; | |
| 70 | }) { | |
| 71 | const setOpen = useCallback((next: boolean) => onOpenChange(next), [onOpenChange]); | |
| 72 | const setOpenMobile = useCallback((next: boolean) => onOpenMobileChange(next), [onOpenMobileChange]); | |
| 73 | const value = useMemo<SidebarContextValue>( | |
| 74 | () => ({ open, setOpen, openMobile, setOpenMobile, toggleSidebar, state: open ? "expanded" : "collapsed" }), | |
| 75 | [open, setOpen, openMobile, setOpenMobile, toggleSidebar], | |
| 76 | ); | |
| 77 | return ( | |
| 78 | <SidebarContext.Provider value={value}> | |
| 79 | <div | |
| 80 | data-slot="sidebar-wrapper" | |
| 81 | style={{ "--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, ...style } as CSSProperties} | |
| 82 | className={cn("group/sidebar-wrapper contents", className)} | |
| 83 | {...props} | |
| 84 | > | |
| 85 | {children} | |
| 86 | </div> | |
| 87 | </SidebarContext.Provider> | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * The column itself. `variant="sidebar"` is the mode sidebar: the | |
| 93 | * sidebar's surface, a hairline on its page side. `variant="rail"` is the | |
| 94 | * narrow column of icons: the rail's surface. `collapsible="offcanvas"` | |
| 95 | * folds the column away when the provider says it is closed; `"icon"` | |
| 96 | * narrows it to its icons (rows hide their words, `group-data- | |
| 97 | * [collapsible=icon]`); `"none"` always shows it in full. The column is | |
| 98 | * positioned by whoever holds it (`className`); `hiddenBelow` names the | |
| 99 | * breakpoint under which only the drawer (`mobile`) is drawn. | |
| 100 | */ | |
| 101 | export function Sidebar({ | |
| 102 | side = "left", | |
| 103 | variant = "sidebar", | |
| 104 | collapsible = "offcanvas", | |
| 105 | className, | |
| 106 | children, | |
| 107 | ...props | |
| 108 | }: ComponentProps<"div"> & { | |
| 109 | side?: "left" | "right"; | |
| 110 | variant?: "sidebar" | "rail"; | |
| 111 | collapsible?: "offcanvas" | "icon" | "none"; | |
| 112 | }) { | |
| 113 | const { state } = useSidebar(); | |
| 114 | const collapsed = collapsible !== "none" && state === "collapsed"; | |
| 115 | return ( | |
| 116 | <div | |
| 117 | data-slot="sidebar" | |
| 118 | data-side={side} | |
| 119 | data-variant={variant} | |
| 120 | data-state={collapsible === "none" ? "expanded" : state} | |
| 121 | data-collapsible={collapsed ? collapsible : ""} | |
| 122 | className={cn( | |
| 123 | "group/sidebar flex h-full flex-col text-fg", | |
| 124 | variant === "rail" ? "w-(--sidebar-width-icon) bg-rail" : "w-(--sidebar-width) bg-sidebar", | |
| 125 | side === "left" ? "border-r border-sidebar-border" : "border-l border-sidebar-border", | |
| 126 | collapsible === "icon" && collapsed && "w-(--sidebar-width-icon)", | |
| 127 | collapsible === "offcanvas" && collapsed && "hidden", | |
| 128 | className, | |
| 129 | )} | |
| 130 | {...props} | |
| 131 | > | |
| 132 | {children} | |
| 133 | </div> | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * The same column as a drawer over the page, for a phone or a narrow | |
| 139 | * window: a sheet from the left that holds focus and closes on Escape or a | |
| 140 | * tap outside. `title` names it for a screen reader. | |
| 141 | */ | |
| 142 | export function SidebarMobile({ | |
| 143 | title, | |
| 144 | className, | |
| 145 | children, | |
| 146 | ...props | |
| 147 | }: Omit<ComponentProps<typeof SheetContent>, "side" | "showClose"> & { title: string }) { | |
| 148 | const { openMobile, setOpenMobile } = useSidebar(); | |
| 149 | return ( | |
| 150 | <Sheet open={openMobile} onOpenChange={setOpenMobile}> | |
| 151 | <SheetContent | |
| 152 | side="left" | |
| 153 | showClose={false} | |
| 154 | aria-describedby={undefined} | |
| 155 | data-slot="sidebar" | |
| 156 | data-mobile="true" | |
| 157 | data-state="expanded" | |
| 158 | // Focus would land on the first row and open its hint; the sheet itself takes it instead. | |
| 159 | onOpenAutoFocus={(event) => { | |
| 160 | event.preventDefault(); | |
| 161 | (event.currentTarget as HTMLElement | null)?.focus(); | |
| 162 | }} | |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 163 | // A phone's drawer takes most of the screen: names read whole and rows reach a thumb, with the page still showing beside it. |
| 164 | className={cn("group/sidebar w-[min(22rem,86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)} | |
| The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page. | 165 | {...props} |
| 166 | > | |
| 167 | <SheetTitle className="sr-only">{title}</SheetTitle> | |
| 168 | {children} | |
| 169 | </SheetContent> | |
| 170 | </Sheet> | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | /** The button that shows or hides the sidebar: the panel icon, with its shortcut as the hint. */ | |
| 175 | export function SidebarTrigger({ hint, className, onClick, ...props }: ComponentProps<typeof Button> & { hint?: ReactNode }) { | |
| 176 | const { open, toggleSidebar } = useSidebar(); | |
| 177 | const button = ( | |
| 178 | <Button | |
| 179 | data-slot="sidebar-trigger" | |
| 180 | variant="ghost" | |
| 181 | size="icon-sm" | |
| 182 | aria-label={open ? "Hide the sidebar" : "Show the sidebar"} | |
| 183 | aria-expanded={open} | |
| 184 | onClick={(event) => { | |
| 185 | onClick?.(event); | |
| 186 | if (!event.defaultPrevented) toggleSidebar(); | |
| 187 | }} | |
| 188 | className={className} | |
| 189 | {...props} | |
| 190 | > | |
| 191 | <PanelLeft size={17} /> | |
| 192 | </Button> | |
| 193 | ); | |
| 194 | return hint ? <Hint label={hint}>{button}</Hint> : button; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * The thin strip along the sidebar's page edge that toggles it when | |
| 199 | * pressed: a second way to fold it, for the pointer, beside the trigger. | |
| 200 | */ | |
| 201 | export function SidebarRail({ className, ...props }: ComponentProps<"button">) { | |
| 202 | const { open, toggleSidebar } = useSidebar(); | |
| 203 | return ( | |
| 204 | <button | |
| 205 | type="button" | |
| 206 | data-slot="sidebar-rail" | |
| 207 | aria-label={open ? "Hide the sidebar" : "Show the sidebar"} | |
| 208 | tabIndex={-1} | |
| 209 | onClick={toggleSidebar} | |
| 210 | className={cn( | |
| 211 | "absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-w-resize transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-px hover:after:bg-sidebar-border sm:flex", | |
| 212 | "group-data-[state=collapsed]/sidebar:cursor-e-resize", | |
| 213 | className, | |
| 214 | )} | |
| 215 | {...props} | |
| 216 | /> | |
| 217 | ); | |
| 218 | } | |
| 219 | ||
| 220 | /** The page beside the sidebar, when the two are laid out as one flex row. */ | |
| 221 | export function SidebarInset({ className, ...props }: ComponentProps<"main">) { | |
| 222 | return <main data-slot="sidebar-inset" className={cn("relative flex min-h-dvh min-w-0 flex-1 flex-col bg-bg", className)} {...props} />; | |
| 223 | } | |
| 224 | ||
| 225 | /** A field at the top of the sidebar: search, or jump to. */ | |
| 226 | export function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) { | |
| 227 | return <Input data-slot="sidebar-input" className={cn("h-8 bg-bg shadow-none ring-sidebar-border", className)} {...props} />; | |
| 228 | } | |
| 229 | ||
| 230 | export function SidebarHeader({ className, ...props }: ComponentProps<"div">) { | |
| 231 | return <div data-slot="sidebar-header" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />; | |
| 232 | } | |
| 233 | ||
| 234 | export function SidebarFooter({ className, ...props }: ComponentProps<"div">) { | |
| 235 | return <div data-slot="sidebar-footer" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />; | |
| 236 | } | |
| 237 | ||
| 238 | export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) { | |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 239 | return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} />; |
| The left-hand navigation takes less room and reads at a glance. The dock becomes a rail: a 56-pixel column of icons with the g1t mark on top, the current app marked by a filled square, hover hints naming each with its shortcut, the apps you pinned in a scrolling band, and Apps, People, Workspace and your account at the foot. Each mode's sidebar sits flush against it, 240 pixels wide, opening on Search or jump to and listing its groups under small labels in 36-pixel rows; Ctrl B hides it and the page grows into the room. Every mode's list is built on the shadcn Sidebar now in the kit, so Workspace, Code, People, Notifications, account settings, Chat, Artifacts and Agents all share one frame; the phone keeps its bottom bar and drawer. The workspaces guide describes the rail, the sidebar and the page. | 240 | } |
| 241 | ||
| 242 | /** What scrolls: the groups, between the header and the footer. */ | |
| 243 | export function SidebarContent({ className, ...props }: ComponentProps<"div">) { | |
| 244 | return ( | |
| 245 | <div | |
| 246 | data-slot="sidebar-content" | |
| 247 | className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto [scrollbar-width:thin] group-data-[collapsible=icon]/sidebar:overflow-hidden", className)} | |
| 248 | {...props} | |
| 249 | /> | |
| 250 | ); | |
| 251 | } | |
| 252 | ||
| 253 | export function SidebarGroup({ className, ...props }: ComponentProps<"div">) { | |
| 254 | return <div data-slot="sidebar-group" className={cn("relative flex w-full min-w-0 flex-col px-2", className)} {...props} />; | |
| 255 | } | |
| 256 | ||
| 257 | /** A group's name: small capitals, quiet, with room at the end for an action. */ | |
| 258 | export function SidebarGroupLabel({ className, asChild = false, ...props }: ComponentProps<"div"> & { asChild?: boolean }) { | |
| 259 | const Comp = asChild ? Slot.Root : "div"; | |
| 260 | return ( | |
| 261 | <Comp | |
| 262 | data-slot="sidebar-group-label" | |
| 263 | className={cn( | |
| 264 | "flex h-7 shrink-0 items-center rounded-md px-2 text-[0.6875rem] font-medium tracking-wide text-faint uppercase outline-none transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 focus-visible:ring-accent", | |
| 265 | "group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0", | |
| 266 | "[&>svg]:size-3.5 [&>svg]:shrink-0", | |
| 267 | className, | |
| 268 | )} | |
| 269 | {...props} | |
| 270 | /> | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | /** A small button at the end of a group's label: what the group makes. */ | |
| 275 | export function SidebarGroupAction({ className, asChild = false, ...props }: ComponentProps<"button"> & { asChild?: boolean }) { | |
| 276 | const Comp = asChild ? Slot.Root : "button"; | |
| 277 | return ( | |
| 278 | <Comp | |
| 279 | data-slot="sidebar-group-action" | |
| 280 | className={cn( | |
| 281 | "absolute top-1 right-3 flex aspect-square w-6 items-center justify-center rounded-md p-0 text-faint outline-none transition-colors hover:bg-sidebar-accent hover:text-fg focus-visible:ring-2 focus-visible:ring-accent [&>svg]:size-3.5 [&>svg]:shrink-0", | |
| 282 | // A finger's target is bigger than the button. | |
| 283 | "after:absolute after:-inset-2 md:after:hidden", | |
| 284 | "group-data-[collapsible=icon]/sidebar:hidden", | |
| 285 | className, | |
| 286 | )} | |
| 287 | {...props} | |
| 288 | /> | |
| 289 | ); | |
| 290 | } | |
| 291 | ||
| 292 | export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) { | |
| 293 | return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />; | |
| 294 | } | |
| 295 | ||
| 296 | export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) { | |
| 297 | return <ul data-slot="sidebar-menu" className={cn("flex w-full min-w-0 flex-col gap-px", className)} {...props} />; | |
| 298 | } | |
| 299 | ||
| 300 | export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) { | |
| 301 | return <li data-slot="sidebar-menu-item" className={cn("group/menu-item relative", className)} {...props} />; | |
| 302 | } | |
| 303 | ||
| 304 | /** | |
| 305 | * One row of a menu: an icon, its words, and whatever sits at the end | |
| 306 | * (a count, a chevron). `isActive` fills it; a `tooltip` shows its words | |
| 307 | * while the sidebar is collapsed to icons, or always for `size="icon"`. | |
| 308 | * `variant="faint"` is for what is coming rather than there. | |
| 309 | */ | |
| 310 | export const sidebarMenuButtonVariants = cva( | |
| 311 | [ | |
| 312 | "peer/menu-button group/menu-button flex w-full items-center gap-2.5 overflow-hidden rounded-md text-left outline-none transition-[width,height,padding,color,background-color]", | |
| 313 | "hover:bg-sidebar-accent/60 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent active:bg-sidebar-accent disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", | |
| 314 | "data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-fg data-[state=open]:bg-sidebar-accent data-[state=open]:text-fg", | |
| 315 | "data-[pending=true]:bg-sidebar-accent/60 data-[pending=true]:text-fg", | |
| 316 | "group-data-[collapsible=icon]/sidebar:size-10! group-data-[collapsible=icon]/sidebar:justify-center group-data-[collapsible=icon]/sidebar:p-0!", | |
| 317 | "[&>span:last-child]:truncate [&>svg]:shrink-0 [&_svg]:pointer-events-none", | |
| 318 | ], | |
| 319 | { | |
| 320 | variants: { | |
| 321 | variant: { | |
| 322 | default: "text-muted [&>svg]:text-faint hover:[&>svg]:text-muted data-[active=true]:[&>svg]:text-fg", | |
| 323 | faint: "text-faint hover:text-muted [&>svg]:opacity-80", | |
| 324 | outline: "border border-dashed border-sidebar-border text-muted hover:border-line-strong hover:bg-transparent data-[active=true]:border-accent/50 data-[active=true]:bg-accent/10 data-[active=true]:font-normal", | |
| 325 | }, | |
| 326 | size: { | |
| 327 | // 36px rows, 13px words and 16px icons: the sidebar's row. | |
| 328 | default: "h-9 px-2 text-[0.8125rem] [&>svg]:size-4", | |
| 329 | sm: "h-8 px-2 text-xs [&>svg]:size-3.5", | |
| 330 | lg: "h-11 px-2 text-sm [&>svg]:size-4", | |
| 331 | // The rail's: a 40px rounded square around a 20px icon, no words. | |
| 332 | icon: "relative size-10 justify-center overflow-visible rounded-[10px] p-0 text-muted hover:bg-sidebar-accent hover:text-fg data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>svg]:size-5 [&>svg]:text-current", | |
| 333 | }, | |
| 334 | }, | |
| 335 | defaultVariants: { variant: "default", size: "default" }, | |
| 336 | }, | |
| 337 | ); | |
| 338 | ||
| 339 | export function SidebarMenuButton({ | |
| 340 | asChild = false, | |
| 341 | isActive = false, | |
| 342 | isPending = false, | |
| 343 | variant, | |
| 344 | size, | |
| 345 | tooltip, | |
| 346 | className, | |
| 347 | ...props | |
| 348 | }: ComponentProps<"button"> & | |
| 349 | VariantProps<typeof sidebarMenuButtonVariants> & { | |
| 350 | asChild?: boolean; | |
| 351 | isActive?: boolean; | |
| 352 | /** The page it leads to is on its way. */ | |
| 353 | isPending?: boolean; | |
| 354 | /** Its words, shown beside it on hover: always for `size="icon"`, else only while collapsed to icons. */ | |
| 355 | tooltip?: ReactNode; | |
| 356 | }) { | |
| 357 | const Comp = asChild ? Slot.Root : "button"; | |
| 358 | const { state } = useSidebar(); | |
| 359 | const button = ( | |
| 360 | <Comp | |
| 361 | data-slot="sidebar-menu-button" | |
| 362 | data-size={size ?? "default"} | |
| 363 | data-active={isActive || undefined} | |
| 364 | data-pending={isPending || undefined} | |
| 365 | className={cn(sidebarMenuButtonVariants({ variant, size }), className)} | |
| 366 | {...props} | |
| 367 | /> | |
| 368 | ); | |
| 369 | if (!tooltip || (size !== "icon" && state !== "collapsed")) return button; | |
| 370 | return ( | |
| 371 | <Hint label={tooltip} side="right"> | |
| 372 | {button} | |
| 373 | </Hint> | |
| 374 | ); | |
| 375 | } | |
| 376 | ||
| 377 | /** A small button at the end of a row, shown on hover (`showOnHover`) or always. */ | |
| 378 | export function SidebarMenuAction({ | |
| 379 | className, | |
| 380 | asChild = false, | |
| 381 | showOnHover = false, | |
| 382 | ...props | |
| 383 | }: ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) { | |
| 384 | const Comp = asChild ? Slot.Root : "button"; | |
| 385 | return ( | |
| 386 | <Comp | |
| 387 | data-slot="sidebar-menu-action" | |
| 388 | className={cn( | |
| 389 | "absolute top-1/2 right-1 flex aspect-square w-6 -translate-y-1/2 items-center justify-center rounded-md p-0 text-faint outline-none transition-colors hover:bg-line hover:text-fg focus-visible:ring-2 focus-visible:ring-accent [&>svg]:size-3.5 [&>svg]:shrink-0", | |
| 390 | "after:absolute after:-inset-2 md:after:hidden", | |
| 391 | "group-data-[collapsible=icon]/sidebar:hidden", | |
| 392 | showOnHover && | |
| 393 | "opacity-0 group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100 [@media(hover:none)]:opacity-100", | |
| 394 | className, | |
| 395 | )} | |
| 396 | {...props} | |
| 397 | /> | |
| 398 | ); | |
| 399 | } | |
| 400 | ||
| 401 | /** A count at the end of a row: how many of what the row lists. */ | |
| 402 | export function SidebarMenuBadge({ className, ...props }: ComponentProps<"span">) { | |
| 403 | return ( | |
| 404 | <span | |
| 405 | data-slot="sidebar-menu-badge" | |
| 406 | className={cn( | |
| 407 | "pointer-events-none ml-auto flex h-5 min-w-5 shrink-0 items-center justify-center rounded px-1.5 text-[0.6875rem] font-medium text-muted tabular-nums select-none", | |
| 408 | "bg-line peer-data-[active=true]/menu-button:bg-line-strong/60", | |
| 409 | "group-data-[collapsible=icon]/sidebar:hidden", | |
| 410 | className, | |
| 411 | )} | |
| 412 | {...props} | |
| 413 | /> | |
| 414 | ); | |
| 415 | } | |
| 416 | ||
| 417 | /** A row that is still on its way: a grey where the icon and words will be. */ | |
| 418 | export function SidebarMenuSkeleton({ className, showIcon = false, ...props }: ComponentProps<"div"> & { showIcon?: boolean }) { | |
| 419 | const width = useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []); | |
| 420 | return ( | |
| 421 | <div data-slot="sidebar-menu-skeleton" className={cn("flex h-9 items-center gap-2.5 rounded-md px-2", className)} {...props}> | |
| 422 | {showIcon && <div className="size-4 animate-pulse rounded-md bg-raised" />} | |
| 423 | <div className="h-3.5 max-w-(--skeleton-width) flex-1 animate-pulse rounded bg-raised" style={{ "--skeleton-width": width } as CSSProperties} /> | |
| 424 | </div> | |
| 425 | ); | |
| 426 | } | |
| 427 | ||
| 428 | /** Rows under a row: a list indented by a hairline. */ | |
| 429 | export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) { | |
| 430 | return ( | |
| 431 | <ul | |
| 432 | data-slot="sidebar-menu-sub" | |
| 433 | className={cn("mx-3.5 flex min-w-0 translate-x-px flex-col gap-px border-l border-sidebar-border px-2.5 py-0.5", "group-data-[collapsible=icon]/sidebar:hidden", className)} | |
| 434 | {...props} | |
| 435 | /> | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | export function SidebarMenuSubItem({ className, ...props }: ComponentProps<"li">) { | |
| 440 | return <li data-slot="sidebar-menu-sub-item" className={cn("group/menu-sub-item relative", className)} {...props} />; | |
| 441 | } | |
| 442 | ||
| 443 | export function SidebarMenuSubButton({ | |
| 444 | asChild = false, | |
| 445 | size = "md", | |
| 446 | isActive = false, | |
| 447 | className, | |
| 448 | ...props | |
| 449 | }: ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) { | |
| 450 | const Comp = asChild ? Slot.Root : "a"; | |
| 451 | return ( | |
| 452 | <Comp | |
| 453 | data-slot="sidebar-menu-sub-button" | |
| 454 | data-size={size} | |
| 455 | data-active={isActive || undefined} | |
| 456 | className={cn( | |
| 457 | "flex h-8 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-muted outline-none hover:bg-sidebar-accent/60 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent", | |
| 458 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-faint", | |
| 459 | size === "sm" ? "text-xs" : "text-[0.8125rem]", | |
| 460 | "group-data-[collapsible=icon]/sidebar:hidden", | |
| 461 | className, | |
| 462 | )} | |
| 463 | {...props} | |
| 464 | /> | |
| 465 | ); | |
| 466 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.