Skip to content
465 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 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. */
174export 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 */
200export 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. */
220export 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. */
225export 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
229export 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
233export 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
237export 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. */
242export 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
252export 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. */
257export 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. */
274export 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
291export function SidebarGroupContent({ className, ...props }: ComponentProps<"div">) {
292 return <div data-slot="sidebar-group-content" className={cn("w-full text-sm", className)} {...props} />;
293}
294
295export 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
299export 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 */
309export 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
338export 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. */
377export 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. */
401export 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. */
417export 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. */
428export 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
438export 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
442export 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}