A sidebar component in neobrutalism style.
1"use client"
2
3import { useIsMobile } from "@/hooks/use-mobile"
4import { Slot } from "@radix-ui/react-slot"
5import { cva, VariantProps } from "class-variance-authority"
6import { PanelLeftIcon } from "lucide-react"
7
8import * as React from "react"
9
10import { Button } from "@/components/ui/button"
11import { Input } from "@/components/ui/input"
12import {
13 Sheet,
14 SheetContent,
15 SheetDescription,
16 SheetHeader,
17 SheetTitle,
18} from "@/components/ui/sheet"
19import { Skeleton } from "@/components/ui/skeleton"
20import {
21 Tooltip,
22 TooltipContent,
23 TooltipProvider,
24 TooltipTrigger,
25} from "@/components/ui/tooltip"
26
27import { cn } from "@/lib/utils"
28
29const SIDEBAR_COOKIE_NAME = "sidebar_state"
30const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
31const SIDEBAR_WIDTH = "16rem"
32const SIDEBAR_WIDTH_MOBILE = "18rem"
33const SIDEBAR_WIDTH_ICON = "3rem"
34const SIDEBAR_KEYBOARD_SHORTCUT = "b"
35
36type SidebarContextProps = {
37 state: "expanded" | "collapsed"
38 open: boolean
39 setOpen: (open: boolean) => void
40 openMobile: boolean
41 setOpenMobile: (open: boolean) => void
42 isMobile: boolean
43 toggleSidebar: () => void
44}
45
46const SidebarContext = React.createContext<SidebarContextProps | null>(null)
47
48function useSidebar() {
49 const context = React.useContext(SidebarContext)
50 if (!context) {
51 throw new Error("useSidebar must be used within a SidebarProvider.")
52 }
53
54 return context
55}
56
57function SidebarProvider({
58 defaultOpen = true,
59 open: openProp,
60 onOpenChange: setOpenProp,
61 className,
62 style,
63 children,
64 ...props
65}: React.ComponentProps<"div"> & {
66 defaultOpen?: boolean
67 open?: boolean
68 onOpenChange?: (open: boolean) => void
69}) {
70 const isMobile = useIsMobile()
71 const [openMobile, setOpenMobile] = React.useState(false)
72
73 // This is the internal state of the sidebar.
74 // We use openProp and setOpenProp for control from outside the component.
75 const [_open, _setOpen] = React.useState(defaultOpen)
76 const open = openProp ?? _open
77 const setOpen = React.useCallback(
78 (value: boolean | ((value: boolean) => boolean)) => {
79 const openState = typeof value === "function" ? value(open) : value
80 if (setOpenProp) {
81 setOpenProp(openState)
82 } else {
83 _setOpen(openState)
84 }
85
86 // This sets the cookie to keep the sidebar state.
87 document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
88 },
89 [setOpenProp, open],
90 )
91
92 // Helper to toggle the sidebar.
93 const toggleSidebar = React.useCallback(() => {
94 return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
95 }, [isMobile, setOpen, setOpenMobile])
96
97 // Adds a keyboard shortcut to toggle the sidebar.
98 React.useEffect(() => {
99 const handleKeyDown = (event: KeyboardEvent) => {
100 if (
101 event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
102 (event.metaKey || event.ctrlKey)
103 ) {
104 event.preventDefault()
105 toggleSidebar()
106 }
107 }
108
109 window.addEventListener("keydown", handleKeyDown)
110 return () => window.removeEventListener("keydown", handleKeyDown)
111 }, [toggleSidebar])
112
113 // We add a state so that we can do data-state="expanded" or "collapsed".
114 // This makes it easier to style the sidebar with Tailwind classes.
115 const state = open ? "expanded" : "collapsed"
116
117 const contextValue = React.useMemo<SidebarContextProps>(
118 () => ({
119 state,
120 open,
121 setOpen,
122 isMobile,
123 openMobile,
124 setOpenMobile,
125 toggleSidebar,
126 }),
127 [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
128 )
129
130 return (
131 <SidebarContext.Provider value={contextValue}>
132 <TooltipProvider delayDuration={0}>
133 <div
134 data-slot="sidebar-wrapper"
135 style={
136 {
137 "--sidebar-width": SIDEBAR_WIDTH,
138 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
139 ...style,
140 } as React.CSSProperties
141 }
142 className={cn(
143 "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
144 className,
145 )}
146 {...props}
147 >
148 {children}
149 </div>
150 </TooltipProvider>
151 </SidebarContext.Provider>
152 )
153}
154
155function Sidebar({
156 side = "left",
157 variant = "sidebar",
158 collapsible = "offcanvas",
159 className,
160 children,
161 ...props
162}: React.ComponentProps<"div"> & {
163 side?: "left" | "right"
164 variant?: "sidebar" | "floating" | "inset"
165 collapsible?: "offcanvas" | "icon" | "none"
166}) {
167 const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
168
169 if (collapsible === "none") {
170 return (
171 <div
172 data-slot="sidebar"
173 className={cn(
174 "bg-secondary-background text-foreground flex h-full w-(--sidebar-width) flex-col",
175 className,
176 )}
177 {...props}
178 >
179 {children}
180 </div>
181 )
182 }
183
184 if (isMobile) {
185 return (
186 <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
187 <SheetContent
188 data-sidebar="sidebar"
189 data-slot="sidebar"
190 data-mobile="true"
191 className="bg-secondary-background text-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
192 style={
193 {
194 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
195 } as React.CSSProperties
196 }
197 side={side}
198 >
199 <SheetHeader className="sr-only">
200 <SheetTitle>Sidebar</SheetTitle>
201 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
202 </SheetHeader>
203 <div className="flex h-full w-full flex-col">{children}</div>
204 </SheetContent>
205 </Sheet>
206 )
207 }
208
209 return (
210 <div
211 className="group peer hidden md:block"
212 data-state={state}
213 data-collapsible={state === "collapsed" ? collapsible : ""}
214 data-variant={variant}
215 data-side={side}
216 data-slot="sidebar"
217 >
218 {/* This is what handles the sidebar gap on desktop */}
219 <div
220 data-slot="sidebar-gap"
221 className={cn(
222 "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
223 "group-data-[collapsible=offcanvas]:w-0",
224 "group-data-[side=right]:rotate-180",
225 variant === "floating" || variant === "inset"
226 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
227 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
228 )}
229 />
230 <div
231 data-slot="sidebar-container"
232 className={cn(
233 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
234 side === "left"
235 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
236 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
237 // Adjust the padding for floating and inset variants.
238 variant === "floating" || variant === "inset"
239 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
240 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r-2 border-r-border group-data-[side=right]:border-l-2 border-l-border",
241 className,
242 )}
243 {...props}
244 >
245 <div
246 data-sidebar="sidebar"
247 data-slot="sidebar-inner"
248 className="bg-secondary-background flex h-full w-full flex-col"
249 >
250 {children}
251 </div>
252 </div>
253 </div>
254 )
255}
256
257function SidebarTrigger({
258 className,
259 onClick,
260 ...props
261}: React.ComponentProps<typeof Button>) {
262 const { toggleSidebar } = useSidebar()
263
264 return (
265 <Button
266 data-sidebar="trigger"
267 data-slot="sidebar-trigger"
268 variant="noShadow"
269 size="icon"
270 className={cn("size-7", className)}
271 onClick={(event) => {
272 onClick?.(event)
273 toggleSidebar()
274 }}
275 {...props}
276 >
277 <PanelLeftIcon />
278 <span className="sr-only">Toggle Sidebar</span>
279 </Button>
280 )
281}
282
283function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
284 const { toggleSidebar } = useSidebar()
285
286 return (
287 <button
288 data-sidebar="rail"
289 data-slot="sidebar-rail"
290 aria-label="Toggle Sidebar"
291 tabIndex={-1}
292 onClick={toggleSidebar}
293 title="Toggle Sidebar"
294 className={cn(
295 "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
296 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
297 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
298 "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
299 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
300 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
301 className,
302 )}
303 {...props}
304 />
305 )
306}
307
308function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
309 return (
310 <main
311 data-slot="sidebar-inset"
312 className={cn(
313 "bg-secondary-background relative flex w-full flex-1 flex-col",
314 "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-base md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
315 className,
316 )}
317 {...props}
318 />
319 )
320}
321
322function SidebarInput({
323 className,
324 ...props
325}: React.ComponentProps<typeof Input>) {
326 return (
327 <Input
328 data-slot="sidebar-input"
329 data-sidebar="input"
330 className={cn(
331 "bg-secondary-background h-8 w-full shadow-none",
332 className,
333 )}
334 {...props}
335 />
336 )
337}
338
339function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
340 return (
341 <div
342 data-slot="sidebar-header"
343 data-sidebar="header"
344 className={cn(
345 "flex flex-col gap-2 p-2 border-b-2 border-b-border",
346 className,
347 )}
348 {...props}
349 />
350 )
351}
352
353function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
354 return (
355 <div
356 data-slot="sidebar-footer"
357 data-sidebar="footer"
358 className={cn(
359 "flex flex-col gap-2 p-2 border-t-2 border-t-border",
360 className,
361 )}
362 {...props}
363 />
364 )
365}
366
367function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
368 return (
369 <div
370 data-slot="sidebar-content"
371 data-sidebar="content"
372 className={cn(
373 "flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden",
374 className,
375 )}
376 {...props}
377 />
378 )
379}
380
381function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
382 return (
383 <div
384 data-slot="sidebar-group"
385 data-sidebar="group"
386 className={cn(
387 "relative flex w-full min-w-0 flex-col p-2 border-b-2 border-b-border last:border-b-0",
388 className,
389 )}
390 {...props}
391 />
392 )
393}
394
395function SidebarGroupLabel({
396 className,
397 asChild = false,
398 ...props
399}: React.ComponentProps<"div"> & { asChild?: boolean }) {
400 const Comp = asChild ? Slot : "div"
401
402 return (
403 <Comp
404 data-slot="sidebar-group-label"
405 data-sidebar="group-label"
406 className={cn(
407 "text-foreground ring-ring flex h-8 shrink-0 items-center rounded-base px-2 text-sm font-heading outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
408 "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
409 className,
410 )}
411 {...props}
412 />
413 )
414}
415
416function SidebarGroupAction({
417 className,
418 asChild = false,
419 ...props
420}: React.ComponentProps<"button"> & { asChild?: boolean }) {
421 const Comp = asChild ? Slot : "button"
422
423 return (
424 <Comp
425 data-slot="sidebar-group-action"
426 data-sidebar="group-action"
427 className={cn(
428 "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-base p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
429 // Increases the hit area of the button on mobile.
430 "after:absolute after:-inset-2 md:after:hidden",
431 "group-data-[collapsible=icon]:hidden",
432 className,
433 )}
434 {...props}
435 />
436 )
437}
438
439function SidebarGroupContent({
440 className,
441 ...props
442}: React.ComponentProps<"div">) {
443 return (
444 <div
445 data-slot="sidebar-group-content"
446 data-sidebar="group-content"
447 className={cn("w-full text-sm", className)}
448 {...props}
449 />
450 )
451}
452
453function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
454 return (
455 <ul
456 data-slot="sidebar-menu"
457 data-sidebar="menu"
458 className={cn("flex w-full min-w-0 flex-col gap-1", className)}
459 {...props}
460 />
461 )
462}
463
464function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
465 return (
466 <li
467 data-slot="sidebar-menu-item"
468 data-sidebar="menu-item"
469 className={cn("group/menu-item relative font-base", className)}
470 {...props}
471 />
472 )
473}
474
475const sidebarMenuButtonVariants = cva(
476 "peer/menu-button flex w-full items-center gap-2 overflow-hidden outline-2 outline-transparent rounded-base p-2 text-left text-sm ring-ring transition-[width,height,padding] hover:bg-main hover:text-main-foreground hover:outline-border focus-visible:outline-border focus-visible:text-main-foreground focus-visible:bg-main disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
477 {
478 variants: {
479 size: {
480 default: "h-8 text-sm",
481 sm: "h-7 text-xs",
482 lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
483 },
484 },
485 defaultVariants: {
486 size: "default",
487 },
488 },
489)
490
491function SidebarMenuButton({
492 asChild = false,
493 isActive = false,
494 size = "default",
495 tooltip,
496 className,
497 ...props
498}: React.ComponentProps<"button"> & {
499 asChild?: boolean
500 isActive?: boolean
501 tooltip?: string | React.ComponentProps<typeof TooltipContent>
502} & VariantProps<typeof sidebarMenuButtonVariants>) {
503 const Comp = asChild ? Slot : "button"
504 const { isMobile, state } = useSidebar()
505
506 const button = (
507 <Comp
508 data-slot="sidebar-menu-button"
509 data-sidebar="menu-button"
510 data-size={size}
511 data-active={isActive}
512 className={cn(sidebarMenuButtonVariants({ size }), className)}
513 {...props}
514 />
515 )
516
517 if (!tooltip) {
518 return button
519 }
520
521 if (typeof tooltip === "string") {
522 tooltip = {
523 children: tooltip,
524 }
525 }
526
527 return (
528 <Tooltip>
529 <TooltipTrigger asChild>{button}</TooltipTrigger>
530 <TooltipContent
531 side="right"
532 align="center"
533 hidden={state !== "collapsed" || isMobile}
534 {...tooltip}
535 />
536 </Tooltip>
537 )
538}
539
540function SidebarMenuAction({
541 className,
542 asChild = false,
543 showOnHover = false,
544 ...props
545}: React.ComponentProps<"button"> & {
546 asChild?: boolean
547 showOnHover?: boolean
548}) {
549 const Comp = asChild ? Slot : "button"
550
551 return (
552 <Comp
553 data-slot="sidebar-menu-action"
554 data-sidebar="menu-action"
555 className={cn(
556 "[&_svg]:text-foreground hover:[&_svg]:text-main-foreground text-main-foreground hover:bg-main hover:outline-border outline-transparent outline-2 absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-base p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
557 // Increases the hit area of the button on mobile.
558 "after:absolute after:-inset-2 md:after:hidden",
559 "peer-data-[size=sm]/menu-button:top-1",
560 "peer-data-[size=default]/menu-button:top-1.5",
561 "peer-data-[size=lg]/menu-button:top-2.5",
562 "group-data-[collapsible=icon]:hidden",
563 className,
564 )}
565 {...props}
566 />
567 )
568}
569
570function SidebarMenuBadge({
571 className,
572 ...props
573}: React.ComponentProps<"div">) {
574 return (
575 <div
576 data-slot="sidebar-menu-badge"
577 data-sidebar="menu-badge"
578 className={cn(
579 "text-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-base px-1 text-xs font-base tabular-nums select-none",
580 "peer-hover/menu-button:text-main-foreground",
581 "peer-data-[size=sm]/menu-button:top-1",
582 "peer-data-[size=default]/menu-button:top-1.5",
583 "peer-data-[size=lg]/menu-button:top-2.5",
584 "group-data-[collapsible=icon]:hidden",
585 className,
586 )}
587 {...props}
588 />
589 )
590}
591
592function SidebarMenuSkeleton({
593 className,
594 showIcon = false,
595 ...props
596}: React.ComponentProps<"div"> & {
597 showIcon?: boolean
598}) {
599 // Random width between 50 to 90%.
600 const width = React.useMemo(() => {
601 return `${Math.floor(Math.random() * 40) + 50}%`
602 }, [])
603
604 return (
605 <div
606 data-slot="sidebar-menu-skeleton"
607 data-sidebar="menu-skeleton"
608 className={cn("flex h-8 items-center gap-2 rounded-base px-2", className)}
609 {...props}
610 >
611 {showIcon && (
612 <Skeleton
613 className="size-4 rounded-base"
614 data-sidebar="menu-skeleton-icon"
615 />
616 )}
617 <Skeleton
618 className="h-4 max-w-(--skeleton-width) flex-1"
619 data-sidebar="menu-skeleton-text"
620 style={
621 {
622 "--skeleton-width": width,
623 } as React.CSSProperties
624 }
625 />
626 </div>
627 )
628}
629
630function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
631 return (
632 <ul
633 data-slot="sidebar-menu-sub"
634 data-sidebar="menu-sub"
635 className={cn(
636 "border-l-foreground/50 mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l-2 px-2.5 py-0.5",
637 "group-data-[collapsible=icon]:hidden",
638 className,
639 )}
640 {...props}
641 />
642 )
643}
644
645function SidebarMenuSubItem({
646 className,
647 ...props
648}: React.ComponentProps<"li">) {
649 return (
650 <li
651 data-slot="sidebar-menu-sub-item"
652 data-sidebar="menu-sub-item"
653 className={cn("group/menu-sub-item relative", className)}
654 {...props}
655 />
656 )
657}
658
659function SidebarMenuSubButton({
660 asChild = false,
661 size = "md",
662 isActive = false,
663 className,
664 ...props
665}: React.ComponentProps<"a"> & {
666 asChild?: boolean
667 size?: "sm" | "md"
668 isActive?: boolean
669}) {
670 const Comp = asChild ? Slot : "a"
671
672 return (
673 <Comp
674 data-slot="sidebar-menu-sub-button"
675 data-sidebar="menu-sub-button"
676 data-size={size}
677 data-active={isActive}
678 className={cn(
679 "text-foreground hover:bg-main hover:outline-border hover:text-main-foreground active:bg-main outline-transparent outline-2 [&>svg]:text-main-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-base px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
680 "data-[active=true]:bg-main data-[active=true]:outline-border",
681 size === "sm" && "text-xs",
682 size === "md" && "text-sm",
683 "group-data-[collapsible=icon]:hidden",
684 className,
685 )}
686 {...props}
687 />
688 )
689}
690
691export {
692 Sidebar,
693 SidebarContent,
694 SidebarFooter,
695 SidebarGroup,
696 SidebarGroupAction,
697 SidebarGroupContent,
698 SidebarGroupLabel,
699 SidebarHeader,
700 SidebarInput,
701 SidebarInset,
702 SidebarMenu,
703 SidebarMenuAction,
704 SidebarMenuBadge,
705 SidebarMenuButton,
706 SidebarMenuItem,
707 SidebarMenuSkeleton,
708 SidebarMenuSub,
709 SidebarMenuSubButton,
710 SidebarMenuSubItem,
711 SidebarProvider,
712 SidebarRail,
713 SidebarTrigger,
714 useSidebar,
715}
716