Skip to content
466 linesCodeBlameRaw
1import { type VariantProps, cva } from "class-variance-authority";
2import { PanelLeft } from "lucide-react";
3import { Slot } from "radix-ui";
4import { type ComponentProps, type CSSProperties, type ReactNode, createContext, useCallback, useContext, useMemo } from "react";
5
6import { cn } from "../../lib/cn";
7import { Button } from "./button";
8import { Hint } from "./hint";
9import { Input } from "./input";
10import { Separator } from "./separator";
11import { 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
29export const SIDEBAR_WIDTH = "15rem";
30export const SIDEBAR_WIDTH_ICON = "3.5rem";
31
32type 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
44const SidebarContext = createContext<SidebarContextValue | null>(null);
45
46export 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
52export 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 */
101export 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 */
142export 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 // 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)}
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. */
175export 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 */
201export 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. */
221export 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. */
226export 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
230export 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
234export 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
238export function SidebarSeparator({ className, ...props }: ComponentProps<typeof Separator>) {
239 return <Separator data-slot="sidebar-separator" className={cn("mx-2 my-2 bg-sidebar-border data-[orientation=horizontal]:w-auto", className)} {...props} />;
240}
241
242/** What scrolls: the groups, between the header and the footer. */
243export 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
253export 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. */
258export 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. */
275export 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
292export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
293 return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />;
294}
295
296export 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
300export 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 */
310export 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
339export 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. */
378export 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. */
402export 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. */
418export 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. */
429export 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
439export 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
443export 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}