| 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 | }} |
| 163 | className={cn("group/sidebar w-[min(var(--sidebar-width),86vw)] border-sidebar-border bg-sidebar px-0 text-fg", className)} |
| 164 | {...props} |
| 165 | > |
| 166 | <SheetTitle className="sr-only">{title}</SheetTitle> |
| 167 | {children} |
| 168 | </SheetContent> |
| 169 | </Sheet> |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | /** The button that shows or hides the sidebar: the panel icon, with its shortcut as the hint. */ |
| 174 | export function SidebarTrigger({ hint, className, onClick, ...props }: ComponentProps<typeof Button> & { hint?: ReactNode }) { |
| 175 | const { open, toggleSidebar } = useSidebar(); |
| 176 | const button = ( |
| 177 | <Button |
| 178 | data-slot="sidebar-trigger" |
| 179 | variant="ghost" |
| 180 | size="icon-sm" |
| 181 | aria-label={open ? "Hide the sidebar" : "Show the sidebar"} |
| 182 | aria-expanded={open} |
| 183 | onClick={(event) => { |
| 184 | onClick?.(event); |
| 185 | if (!event.defaultPrevented) toggleSidebar(); |
| 186 | }} |
| 187 | className={className} |
| 188 | {...props} |
| 189 | > |
| 190 | <PanelLeft size={17} /> |
| 191 | </Button> |
| 192 | ); |
| 193 | return hint ? <Hint label={hint}>{button}</Hint> : button; |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * The thin strip along the sidebar's page edge that toggles it when |
| 198 | * pressed: a second way to fold it, for the pointer, beside the trigger. |
| 199 | */ |
| 200 | export function SidebarRail({ className, ...props }: ComponentProps<"button">) { |
| 201 | const { open, toggleSidebar } = useSidebar(); |
| 202 | return ( |
| 203 | <button |
| 204 | type="button" |
| 205 | data-slot="sidebar-rail" |
| 206 | aria-label={open ? "Hide the sidebar" : "Show the sidebar"} |
| 207 | tabIndex={-1} |
| 208 | onClick={toggleSidebar} |
| 209 | className={cn( |
| 210 | "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", |
| 211 | "group-data-[state=collapsed]/sidebar:cursor-e-resize", |
| 212 | className, |
| 213 | )} |
| 214 | {...props} |
| 215 | /> |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | /** The page beside the sidebar, when the two are laid out as one flex row. */ |
| 220 | export function SidebarInset({ className, ...props }: ComponentProps<"main">) { |
| 221 | return <main data-slot="sidebar-inset" className={cn("relative flex min-h-dvh min-w-0 flex-1 flex-col bg-bg", className)} {...props} />; |
| 222 | } |
| 223 | |
| 224 | /** A field at the top of the sidebar: search, or jump to. */ |
| 225 | export function SidebarInput({ className, ...props }: ComponentProps<typeof Input>) { |
| 226 | return <Input data-slot="sidebar-input" className={cn("h-8 bg-bg shadow-none ring-sidebar-border", className)} {...props} />; |
| 227 | } |
| 228 | |
| 229 | export function SidebarHeader({ className, ...props }: ComponentProps<"div">) { |
| 230 | return <div data-slot="sidebar-header" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />; |
| 231 | } |
| 232 | |
| 233 | export function SidebarFooter({ className, ...props }: ComponentProps<"div">) { |
| 234 | return <div data-slot="sidebar-footer" className={cn("flex shrink-0 flex-col gap-2 p-2", className)} {...props} />; |
| 235 | } |
| 236 | |
| 237 | export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) { |
| 238 | return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 w-auto bg-sidebar-border", className)} {...props} />; |
| 239 | } |
| 240 | |
| 241 | /** What scrolls: the groups, between the header and the footer. */ |
| 242 | export function SidebarContent({ className, ...props }: ComponentProps<"div">) { |
| 243 | return ( |
| 244 | <div |
| 245 | data-slot="sidebar-content" |
| 246 | 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)} |
| 247 | {...props} |
| 248 | /> |
| 249 | ); |
| 250 | } |
| 251 | |
| 252 | export function SidebarGroup({ className, ...props }: ComponentProps<"div">) { |
| 253 | return <div data-slot="sidebar-group" className={cn("relative flex w-full min-w-0 flex-col px-2", className)} {...props} />; |
| 254 | } |
| 255 | |
| 256 | /** A group's name: small capitals, quiet, with room at the end for an action. */ |
| 257 | export function SidebarGroupLabel({ className, asChild = false, ...props }: ComponentProps<"div"> & { asChild?: boolean }) { |
| 258 | const Comp = asChild ? Slot.Root : "div"; |
| 259 | return ( |
| 260 | <Comp |
| 261 | data-slot="sidebar-group-label" |
| 262 | className={cn( |
| 263 | "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", |
| 264 | "group-data-[collapsible=icon]/sidebar:-mt-8 group-data-[collapsible=icon]/sidebar:opacity-0", |
| 265 | "[&>svg]:size-3.5 [&>svg]:shrink-0", |
| 266 | className, |
| 267 | )} |
| 268 | {...props} |
| 269 | /> |
| 270 | ); |
| 271 | } |
| 272 | |
| 273 | /** A small button at the end of a group's label: what the group makes. */ |
| 274 | export function SidebarGroupAction({ className, asChild = false, ...props }: ComponentProps<"button"> & { asChild?: boolean }) { |
| 275 | const Comp = asChild ? Slot.Root : "button"; |
| 276 | return ( |
| 277 | <Comp |
| 278 | data-slot="sidebar-group-action" |
| 279 | className={cn( |
| 280 | "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", |
| 281 | // A finger's target is bigger than the button. |
| 282 | "after:absolute after:-inset-2 md:after:hidden", |
| 283 | "group-data-[collapsible=icon]/sidebar:hidden", |
| 284 | className, |
| 285 | )} |
| 286 | {...props} |
| 287 | /> |
| 288 | ); |
| 289 | } |
| 290 | |
| 291 | export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) { |
| 292 | return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />; |
| 293 | } |
| 294 | |
| 295 | export function SidebarMenu({ className, ...props }: ComponentProps<"ul">) { |
| 296 | return <ul data-slot="sidebar-menu" className={cn("flex w-full min-w-0 flex-col gap-px", className)} {...props} />; |
| 297 | } |
| 298 | |
| 299 | export function SidebarMenuItem({ className, ...props }: ComponentProps<"li">) { |
| 300 | return <li data-slot="sidebar-menu-item" className={cn("group/menu-item relative", className)} {...props} />; |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * One row of a menu: an icon, its words, and whatever sits at the end |
| 305 | * (a count, a chevron). `isActive` fills it; a `tooltip` shows its words |
| 306 | * while the sidebar is collapsed to icons, or always for `size="icon"`. |
| 307 | * `variant="faint"` is for what is coming rather than there. |
| 308 | */ |
| 309 | export const sidebarMenuButtonVariants = cva( |
| 310 | [ |
| 311 | "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]", |
| 312 | "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", |
| 313 | "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", |
| 314 | "data-[pending=true]:bg-sidebar-accent/60 data-[pending=true]:text-fg", |
| 315 | "group-data-[collapsible=icon]/sidebar:size-10! group-data-[collapsible=icon]/sidebar:justify-center group-data-[collapsible=icon]/sidebar:p-0!", |
| 316 | "[&>span:last-child]:truncate [&>svg]:shrink-0 [&_svg]:pointer-events-none", |
| 317 | ], |
| 318 | { |
| 319 | variants: { |
| 320 | variant: { |
| 321 | default: "text-muted [&>svg]:text-faint hover:[&>svg]:text-muted data-[active=true]:[&>svg]:text-fg", |
| 322 | faint: "text-faint hover:text-muted [&>svg]:opacity-80", |
| 323 | 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", |
| 324 | }, |
| 325 | size: { |
| 326 | // 36px rows, 13px words and 16px icons: the sidebar's row. |
| 327 | default: "h-9 px-2 text-[0.8125rem] [&>svg]:size-4", |
| 328 | sm: "h-8 px-2 text-xs [&>svg]:size-3.5", |
| 329 | lg: "h-11 px-2 text-sm [&>svg]:size-4", |
| 330 | // The rail's: a 40px rounded square around a 20px icon, no words. |
| 331 | 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", |
| 332 | }, |
| 333 | }, |
| 334 | defaultVariants: { variant: "default", size: "default" }, |
| 335 | }, |
| 336 | ); |
| 337 | |
| 338 | export function SidebarMenuButton({ |
| 339 | asChild = false, |
| 340 | isActive = false, |
| 341 | isPending = false, |
| 342 | variant, |
| 343 | size, |
| 344 | tooltip, |
| 345 | className, |
| 346 | ...props |
| 347 | }: ComponentProps<"button"> & |
| 348 | VariantProps<typeof sidebarMenuButtonVariants> & { |
| 349 | asChild?: boolean; |
| 350 | isActive?: boolean; |
| 351 | /** The page it leads to is on its way. */ |
| 352 | isPending?: boolean; |
| 353 | /** Its words, shown beside it on hover: always for `size="icon"`, else only while collapsed to icons. */ |
| 354 | tooltip?: ReactNode; |
| 355 | }) { |
| 356 | const Comp = asChild ? Slot.Root : "button"; |
| 357 | const { state } = useSidebar(); |
| 358 | const button = ( |
| 359 | <Comp |
| 360 | data-slot="sidebar-menu-button" |
| 361 | data-size={size ?? "default"} |
| 362 | data-active={isActive || undefined} |
| 363 | data-pending={isPending || undefined} |
| 364 | className={cn(sidebarMenuButtonVariants({ variant, size }), className)} |
| 365 | {...props} |
| 366 | /> |
| 367 | ); |
| 368 | if (!tooltip || (size !== "icon" && state !== "collapsed")) return button; |
| 369 | return ( |
| 370 | <Hint label={tooltip} side="right"> |
| 371 | {button} |
| 372 | </Hint> |
| 373 | ); |
| 374 | } |
| 375 | |
| 376 | /** A small button at the end of a row, shown on hover (`showOnHover`) or always. */ |
| 377 | export function SidebarMenuAction({ |
| 378 | className, |
| 379 | asChild = false, |
| 380 | showOnHover = false, |
| 381 | ...props |
| 382 | }: ComponentProps<"button"> & { asChild?: boolean; showOnHover?: boolean }) { |
| 383 | const Comp = asChild ? Slot.Root : "button"; |
| 384 | return ( |
| 385 | <Comp |
| 386 | data-slot="sidebar-menu-action" |
| 387 | className={cn( |
| 388 | "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", |
| 389 | "after:absolute after:-inset-2 md:after:hidden", |
| 390 | "group-data-[collapsible=icon]/sidebar:hidden", |
| 391 | showOnHover && |
| 392 | "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", |
| 393 | className, |
| 394 | )} |
| 395 | {...props} |
| 396 | /> |
| 397 | ); |
| 398 | } |
| 399 | |
| 400 | /** A count at the end of a row: how many of what the row lists. */ |
| 401 | export function SidebarMenuBadge({ className, ...props }: ComponentProps<"span">) { |
| 402 | return ( |
| 403 | <span |
| 404 | data-slot="sidebar-menu-badge" |
| 405 | className={cn( |
| 406 | "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", |
| 407 | "bg-line peer-data-[active=true]/menu-button:bg-line-strong/60", |
| 408 | "group-data-[collapsible=icon]/sidebar:hidden", |
| 409 | className, |
| 410 | )} |
| 411 | {...props} |
| 412 | /> |
| 413 | ); |
| 414 | } |
| 415 | |
| 416 | /** A row that is still on its way: a grey where the icon and words will be. */ |
| 417 | export function SidebarMenuSkeleton({ className, showIcon = false, ...props }: ComponentProps<"div"> & { showIcon?: boolean }) { |
| 418 | const width = useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []); |
| 419 | return ( |
| 420 | <div data-slot="sidebar-menu-skeleton" className={cn("flex h-9 items-center gap-2.5 rounded-md px-2", className)} {...props}> |
| 421 | {showIcon && <div className="size-4 animate-pulse rounded-md bg-raised" />} |
| 422 | <div className="h-3.5 max-w-(--skeleton-width) flex-1 animate-pulse rounded bg-raised" style={{ "--skeleton-width": width } as CSSProperties} /> |
| 423 | </div> |
| 424 | ); |
| 425 | } |
| 426 | |
| 427 | /** Rows under a row: a list indented by a hairline. */ |
| 428 | export function SidebarMenuSub({ className, ...props }: ComponentProps<"ul">) { |
| 429 | return ( |
| 430 | <ul |
| 431 | data-slot="sidebar-menu-sub" |
| 432 | 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)} |
| 433 | {...props} |
| 434 | /> |
| 435 | ); |
| 436 | } |
| 437 | |
| 438 | export function SidebarMenuSubItem({ className, ...props }: ComponentProps<"li">) { |
| 439 | return <li data-slot="sidebar-menu-sub-item" className={cn("group/menu-sub-item relative", className)} {...props} />; |
| 440 | } |
| 441 | |
| 442 | export function SidebarMenuSubButton({ |
| 443 | asChild = false, |
| 444 | size = "md", |
| 445 | isActive = false, |
| 446 | className, |
| 447 | ...props |
| 448 | }: ComponentProps<"a"> & { asChild?: boolean; size?: "sm" | "md"; isActive?: boolean }) { |
| 449 | const Comp = asChild ? Slot.Root : "a"; |
| 450 | return ( |
| 451 | <Comp |
| 452 | data-slot="sidebar-menu-sub-button" |
| 453 | data-size={size} |
| 454 | data-active={isActive || undefined} |
| 455 | className={cn( |
| 456 | "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", |
| 457 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-fg [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-faint", |
| 458 | size === "sm" ? "text-xs" : "text-[0.8125rem]", |
| 459 | "group-data-[collapsible=icon]/sidebar:hidden", |
| 460 | className, |
| 461 | )} |
| 462 | {...props} |
| 463 | /> |
| 464 | ); |
| 465 | } |