blob: 48fc8c863198b1b4fe94f7c345cf2cf418cd568b [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import * as React from "react";
2import { Slot } from "@radix-ui/react-slot";
3import { VariantProps, cva } from "class-variance-authority";
4import { PanelLeft } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +04005
giod0026612025-05-08 13:00:36 +00006import { useIsMobile } from "@/hooks/use-mobile";
7import { cn } from "@/lib/utils";
8import { Button } from "@/components/ui/button";
9import { Input } from "@/components/ui/input";
10import { Separator } from "@/components/ui/separator";
11import { Sheet, SheetContent } from "@/components/ui/sheet";
12import { Skeleton } from "@/components/ui/skeleton";
13import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
gio5f2f1002025-03-20 18:38:48 +040014
giod0026612025-05-08 13:00:36 +000015const SIDEBAR_COOKIE_NAME = "sidebar:state";
16const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
17const SIDEBAR_WIDTH = "16rem";
18const SIDEBAR_WIDTH_MOBILE = "18rem";
19const SIDEBAR_WIDTH_ICON = "3rem";
20const SIDEBAR_KEYBOARD_SHORTCUT = "b";
gio5f2f1002025-03-20 18:38:48 +040021
22type SidebarContext = {
giod0026612025-05-08 13:00:36 +000023 state: "expanded" | "collapsed";
24 open: boolean;
25 setOpen: (open: boolean) => void;
26 openMobile: boolean;
27 setOpenMobile: (open: boolean) => void;
28 isMobile: boolean;
29 toggleSidebar: () => void;
30};
gio5f2f1002025-03-20 18:38:48 +040031
giod0026612025-05-08 13:00:36 +000032const SidebarContext = React.createContext<SidebarContext | null>(null);
gio5f2f1002025-03-20 18:38:48 +040033
34function useSidebar() {
giod0026612025-05-08 13:00:36 +000035 const context = React.useContext(SidebarContext);
36 if (!context) {
37 throw new Error("useSidebar must be used within a SidebarProvider.");
38 }
gio5f2f1002025-03-20 18:38:48 +040039
giod0026612025-05-08 13:00:36 +000040 return context;
gio5f2f1002025-03-20 18:38:48 +040041}
42
43const SidebarProvider = React.forwardRef<
giod0026612025-05-08 13:00:36 +000044 HTMLDivElement,
45 React.ComponentProps<"div"> & {
46 defaultOpen?: boolean;
47 open?: boolean;
48 onOpenChange?: (open: boolean) => void;
49 }
50>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
51 const isMobile = useIsMobile();
52 const [openMobile, setOpenMobile] = React.useState(false);
gio5f2f1002025-03-20 18:38:48 +040053
giod0026612025-05-08 13:00:36 +000054 // This is the internal state of the sidebar.
55 // We use openProp and setOpenProp for control from outside the component.
56 const [_open, _setOpen] = React.useState(defaultOpen);
57 const open = openProp ?? _open;
58 const setOpen = React.useCallback(
59 (value: boolean | ((value: boolean) => boolean)) => {
60 const openState = typeof value === "function" ? value(open) : value;
61 if (setOpenProp) {
62 setOpenProp(openState);
63 } else {
64 _setOpen(openState);
65 }
gio5f2f1002025-03-20 18:38:48 +040066
giod0026612025-05-08 13:00:36 +000067 // This sets the cookie to keep the sidebar state.
68 document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
69 },
70 [setOpenProp, open],
71 );
gio5f2f1002025-03-20 18:38:48 +040072
giod0026612025-05-08 13:00:36 +000073 // Helper to toggle the sidebar.
74 const toggleSidebar = React.useCallback(() => {
75 return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
76 }, [isMobile, setOpen, setOpenMobile]);
gio5f2f1002025-03-20 18:38:48 +040077
giod0026612025-05-08 13:00:36 +000078 // Adds a keyboard shortcut to toggle the sidebar.
79 React.useEffect(() => {
80 const handleKeyDown = (event: KeyboardEvent) => {
81 if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
82 event.preventDefault();
83 toggleSidebar();
84 }
85 };
gio5f2f1002025-03-20 18:38:48 +040086
giod0026612025-05-08 13:00:36 +000087 window.addEventListener("keydown", handleKeyDown);
88 return () => window.removeEventListener("keydown", handleKeyDown);
89 }, [toggleSidebar]);
gio5f2f1002025-03-20 18:38:48 +040090
giod0026612025-05-08 13:00:36 +000091 // We add a state so that we can do data-state="expanded" or "collapsed".
92 // This makes it easier to style the sidebar with Tailwind classes.
93 const state = open ? "expanded" : "collapsed";
gio5f2f1002025-03-20 18:38:48 +040094
giod0026612025-05-08 13:00:36 +000095 const contextValue = React.useMemo<SidebarContext>(
96 () => ({
97 state,
98 open,
99 setOpen,
100 isMobile,
101 openMobile,
102 setOpenMobile,
103 toggleSidebar,
104 }),
105 [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
106 );
gio5f2f1002025-03-20 18:38:48 +0400107
giod0026612025-05-08 13:00:36 +0000108 return (
109 <SidebarContext.Provider value={contextValue}>
110 <TooltipProvider delayDuration={0}>
111 <div
112 style={
113 {
114 "--sidebar-width": SIDEBAR_WIDTH,
115 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
116 ...style,
117 } as React.CSSProperties
118 }
119 className={cn(
120 "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
121 className,
122 )}
123 ref={ref}
124 {...props}
125 >
126 {children}
127 </div>
128 </TooltipProvider>
129 </SidebarContext.Provider>
130 );
131});
132SidebarProvider.displayName = "SidebarProvider";
gio5f2f1002025-03-20 18:38:48 +0400133
134const Sidebar = React.forwardRef<
giod0026612025-05-08 13:00:36 +0000135 HTMLDivElement,
136 React.ComponentProps<"div"> & {
137 side?: "left" | "right";
138 variant?: "sidebar" | "floating" | "inset";
139 collapsible?: "offcanvas" | "icon" | "none";
140 }
141>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
142 const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
gio5f2f1002025-03-20 18:38:48 +0400143
giod0026612025-05-08 13:00:36 +0000144 if (collapsible === "none") {
145 return (
146 <div
147 className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
148 ref={ref}
149 {...props}
150 >
151 {children}
152 </div>
153 );
154 }
gio5f2f1002025-03-20 18:38:48 +0400155
giod0026612025-05-08 13:00:36 +0000156 if (isMobile) {
157 return (
158 <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
159 <SheetContent
160 data-sidebar="sidebar"
161 data-mobile="true"
162 className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
163 style={
164 {
165 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
166 } as React.CSSProperties
167 }
168 side={side}
169 >
170 <div className="flex h-full w-full flex-col">{children}</div>
171 </SheetContent>
172 </Sheet>
173 );
174 }
gio5f2f1002025-03-20 18:38:48 +0400175
giod0026612025-05-08 13:00:36 +0000176 return (
177 <div
178 ref={ref}
179 className="group peer hidden md:block text-sidebar-foreground"
180 data-state={state}
181 data-collapsible={state === "collapsed" ? collapsible : ""}
182 data-variant={variant}
183 data-side={side}
184 >
185 {/* This is what handles the sidebar gap on desktop */}
186 <div
187 className={cn(
188 "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
189 "group-data-[collapsible=offcanvas]:w-0",
190 "group-data-[side=right]:rotate-180",
191 variant === "floating" || variant === "inset"
192 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
193 : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
194 )}
195 />
196 <div
197 className={cn(
198 "duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
199 side === "left"
200 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
201 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
202 // Adjust the padding for floating and inset variants.
203 variant === "floating" || variant === "inset"
204 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
205 : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
206 className,
207 )}
208 {...props}
209 >
210 <div
211 data-sidebar="sidebar"
212 className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
213 >
214 {children}
215 </div>
216 </div>
217 </div>
218 );
219});
220Sidebar.displayName = "Sidebar";
gio5f2f1002025-03-20 18:38:48 +0400221
giod0026612025-05-08 13:00:36 +0000222const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
223 ({ className, onClick, ...props }, ref) => {
224 const { toggleSidebar } = useSidebar();
gio5f2f1002025-03-20 18:38:48 +0400225
giod0026612025-05-08 13:00:36 +0000226 return (
227 <Button
228 ref={ref}
229 data-sidebar="trigger"
230 variant="ghost"
231 size="icon"
232 className={cn("h-7 w-7", className)}
233 onClick={(event) => {
234 onClick?.(event);
235 toggleSidebar();
236 }}
237 {...props}
238 >
239 <PanelLeft />
240 <span className="sr-only">Toggle Sidebar</span>
241 </Button>
242 );
243 },
244);
245SidebarTrigger.displayName = "SidebarTrigger";
gio5f2f1002025-03-20 18:38:48 +0400246
giod0026612025-05-08 13:00:36 +0000247const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
248 ({ className, ...props }, ref) => {
249 const { toggleSidebar } = useSidebar();
gio5f2f1002025-03-20 18:38:48 +0400250
giod0026612025-05-08 13:00:36 +0000251 return (
252 <button
253 ref={ref}
254 data-sidebar="rail"
255 aria-label="Toggle Sidebar"
256 tabIndex={-1}
257 onClick={toggleSidebar}
258 title="Toggle Sidebar"
259 className={cn(
260 "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
261 "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
262 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
263 "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
264 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
265 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
266 className,
267 )}
268 {...props}
269 />
270 );
271 },
272);
273SidebarRail.displayName = "SidebarRail";
gio5f2f1002025-03-20 18:38:48 +0400274
giod0026612025-05-08 13:00:36 +0000275const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
276 return (
277 <main
278 ref={ref}
279 className={cn(
280 "relative flex min-h-svh flex-1 flex-col bg-background",
281 "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
282 className,
283 )}
284 {...props}
285 />
286 );
287});
288SidebarInset.displayName = "SidebarInset";
gio5f2f1002025-03-20 18:38:48 +0400289
giod0026612025-05-08 13:00:36 +0000290const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
291 ({ className, ...props }, ref) => {
292 return (
293 <Input
294 ref={ref}
295 data-sidebar="input"
296 className={cn(
297 "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
298 className,
299 )}
300 {...props}
301 />
302 );
303 },
304);
305SidebarInput.displayName = "SidebarInput";
gio5f2f1002025-03-20 18:38:48 +0400306
giod0026612025-05-08 13:00:36 +0000307const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
308 return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
309});
310SidebarHeader.displayName = "SidebarHeader";
gio5f2f1002025-03-20 18:38:48 +0400311
giod0026612025-05-08 13:00:36 +0000312const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
313 return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
314});
315SidebarFooter.displayName = "SidebarFooter";
gio5f2f1002025-03-20 18:38:48 +0400316
giod0026612025-05-08 13:00:36 +0000317const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
318 ({ className, ...props }, ref) => {
319 return (
320 <Separator
321 ref={ref}
322 data-sidebar="separator"
323 className={cn("mx-2 w-auto bg-sidebar-border", className)}
324 {...props}
325 />
326 );
327 },
328);
329SidebarSeparator.displayName = "SidebarSeparator";
gio5f2f1002025-03-20 18:38:48 +0400330
giod0026612025-05-08 13:00:36 +0000331const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
332 return (
333 <div
334 ref={ref}
335 data-sidebar="content"
336 className={cn(
337 "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
338 className,
339 )}
340 {...props}
341 />
342 );
343});
344SidebarContent.displayName = "SidebarContent";
gio5f2f1002025-03-20 18:38:48 +0400345
giod0026612025-05-08 13:00:36 +0000346const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
347 return (
348 <div
349 ref={ref}
350 data-sidebar="group"
351 className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
352 {...props}
353 />
354 );
355});
356SidebarGroup.displayName = "SidebarGroup";
gio5f2f1002025-03-20 18:38:48 +0400357
giod0026612025-05-08 13:00:36 +0000358const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
359 ({ className, asChild = false, ...props }, ref) => {
360 const Comp = asChild ? Slot : "div";
gio5f2f1002025-03-20 18:38:48 +0400361
giod0026612025-05-08 13:00:36 +0000362 return (
363 <Comp
364 ref={ref}
365 data-sidebar="group-label"
366 className={cn(
367 "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
368 "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
369 className,
370 )}
371 {...props}
372 />
373 );
374 },
375);
376SidebarGroupLabel.displayName = "SidebarGroupLabel";
gio5f2f1002025-03-20 18:38:48 +0400377
giod0026612025-05-08 13:00:36 +0000378const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
379 ({ className, asChild = false, ...props }, ref) => {
380 const Comp = asChild ? Slot : "button";
gio5f2f1002025-03-20 18:38:48 +0400381
giod0026612025-05-08 13:00:36 +0000382 return (
383 <Comp
384 ref={ref}
385 data-sidebar="group-action"
386 className={cn(
387 "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
388 // Increases the hit area of the button on mobile.
389 "after:absolute after:-inset-2 after:md:hidden",
390 "group-data-[collapsible=icon]:hidden",
391 className,
392 )}
393 {...props}
394 />
395 );
396 },
397);
398SidebarGroupAction.displayName = "SidebarGroupAction";
gio5f2f1002025-03-20 18:38:48 +0400399
giod0026612025-05-08 13:00:36 +0000400const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
401 ({ className, ...props }, ref) => (
402 <div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
403 ),
404);
405SidebarGroupContent.displayName = "SidebarGroupContent";
gio5f2f1002025-03-20 18:38:48 +0400406
giod0026612025-05-08 13:00:36 +0000407const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
408 <ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
409));
410SidebarMenu.displayName = "SidebarMenu";
gio5f2f1002025-03-20 18:38:48 +0400411
giod0026612025-05-08 13:00:36 +0000412const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
413 <li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
414));
415SidebarMenuItem.displayName = "SidebarMenuItem";
gio5f2f1002025-03-20 18:38:48 +0400416
417const sidebarMenuButtonVariants = cva(
giod0026612025-05-08 13:00:36 +0000418 "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground 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 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
419 {
420 variants: {
421 variant: {
422 default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
423 outline:
424 "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
425 },
426 size: {
427 default: "h-8 text-sm",
428 sm: "h-7 text-xs",
429 lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
430 },
431 },
432 defaultVariants: {
433 variant: "default",
434 size: "default",
435 },
436 },
437);
gio5f2f1002025-03-20 18:38:48 +0400438
439const SidebarMenuButton = React.forwardRef<
giod0026612025-05-08 13:00:36 +0000440 HTMLButtonElement,
441 React.ComponentProps<"button"> & {
442 asChild?: boolean;
443 isActive?: boolean;
444 tooltip?: string | React.ComponentProps<typeof TooltipContent>;
445 } & VariantProps<typeof sidebarMenuButtonVariants>
446>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
447 const Comp = asChild ? Slot : "button";
448 const { isMobile, state } = useSidebar();
gio5f2f1002025-03-20 18:38:48 +0400449
giod0026612025-05-08 13:00:36 +0000450 const button = (
451 <Comp
452 ref={ref}
453 data-sidebar="menu-button"
454 data-size={size}
455 data-active={isActive}
456 className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
457 {...props}
458 />
459 );
gio5f2f1002025-03-20 18:38:48 +0400460
giod0026612025-05-08 13:00:36 +0000461 if (!tooltip) {
462 return button;
463 }
gio5f2f1002025-03-20 18:38:48 +0400464
giod0026612025-05-08 13:00:36 +0000465 if (typeof tooltip === "string") {
466 tooltip = {
467 children: tooltip,
468 };
469 }
gio5f2f1002025-03-20 18:38:48 +0400470
giod0026612025-05-08 13:00:36 +0000471 return (
472 <Tooltip>
473 <TooltipTrigger asChild>{button}</TooltipTrigger>
474 <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
475 </Tooltip>
476 );
477});
478SidebarMenuButton.displayName = "SidebarMenuButton";
gio5f2f1002025-03-20 18:38:48 +0400479
480const SidebarMenuAction = React.forwardRef<
giod0026612025-05-08 13:00:36 +0000481 HTMLButtonElement,
482 React.ComponentProps<"button"> & {
483 asChild?: boolean;
484 showOnHover?: boolean;
485 }
gio5f2f1002025-03-20 18:38:48 +0400486>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
giod0026612025-05-08 13:00:36 +0000487 const Comp = asChild ? Slot : "button";
gio5f2f1002025-03-20 18:38:48 +0400488
giod0026612025-05-08 13:00:36 +0000489 return (
490 <Comp
491 ref={ref}
492 data-sidebar="menu-action"
493 className={cn(
494 "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
495 // Increases the hit area of the button on mobile.
496 "after:absolute after:-inset-2 after:md:hidden",
497 "peer-data-[size=sm]/menu-button:top-1",
498 "peer-data-[size=default]/menu-button:top-1.5",
499 "peer-data-[size=lg]/menu-button:top-2.5",
500 "group-data-[collapsible=icon]:hidden",
501 showOnHover &&
502 "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
503 className,
504 )}
505 {...props}
506 />
507 );
508});
509SidebarMenuAction.displayName = "SidebarMenuAction";
gio5f2f1002025-03-20 18:38:48 +0400510
giod0026612025-05-08 13:00:36 +0000511const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
512 ({ className, ...props }, ref) => (
513 <div
514 ref={ref}
515 data-sidebar="menu-badge"
516 className={cn(
517 "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
518 "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
519 "peer-data-[size=sm]/menu-button:top-1",
520 "peer-data-[size=default]/menu-button:top-1.5",
521 "peer-data-[size=lg]/menu-button:top-2.5",
522 "group-data-[collapsible=icon]:hidden",
523 className,
524 )}
525 {...props}
526 />
527 ),
528);
529SidebarMenuBadge.displayName = "SidebarMenuBadge";
gio5f2f1002025-03-20 18:38:48 +0400530
531const SidebarMenuSkeleton = React.forwardRef<
giod0026612025-05-08 13:00:36 +0000532 HTMLDivElement,
533 React.ComponentProps<"div"> & {
534 showIcon?: boolean;
535 }
gio5f2f1002025-03-20 18:38:48 +0400536>(({ className, showIcon = false, ...props }, ref) => {
giod0026612025-05-08 13:00:36 +0000537 // Random width between 50 to 90%.
538 const width = React.useMemo(() => {
539 return `${Math.floor(Math.random() * 40) + 50}%`;
540 }, []);
gio5f2f1002025-03-20 18:38:48 +0400541
giod0026612025-05-08 13:00:36 +0000542 return (
543 <div
544 ref={ref}
545 data-sidebar="menu-skeleton"
546 className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
547 {...props}
548 >
549 {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
550 <Skeleton
551 className="h-4 flex-1 max-w-[--skeleton-width]"
552 data-sidebar="menu-skeleton-text"
553 style={
554 {
555 "--skeleton-width": width,
556 } as React.CSSProperties
557 }
558 />
559 </div>
560 );
561});
562SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
gio5f2f1002025-03-20 18:38:48 +0400563
giod0026612025-05-08 13:00:36 +0000564const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
565 ({ className, ...props }, ref) => (
566 <ul
567 ref={ref}
568 data-sidebar="menu-sub"
569 className={cn(
570 "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
571 "group-data-[collapsible=icon]:hidden",
572 className,
573 )}
574 {...props}
575 />
576 ),
577);
578SidebarMenuSub.displayName = "SidebarMenuSub";
gio5f2f1002025-03-20 18:38:48 +0400579
giod0026612025-05-08 13:00:36 +0000580const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
581 <li ref={ref} {...props} />
582));
583SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
gio5f2f1002025-03-20 18:38:48 +0400584
585const SidebarMenuSubButton = React.forwardRef<
giod0026612025-05-08 13:00:36 +0000586 HTMLAnchorElement,
587 React.ComponentProps<"a"> & {
588 asChild?: boolean;
589 size?: "sm" | "md";
590 isActive?: boolean;
591 }
gio5f2f1002025-03-20 18:38:48 +0400592>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
giod0026612025-05-08 13:00:36 +0000593 const Comp = asChild ? Slot : "a";
gio5f2f1002025-03-20 18:38:48 +0400594
giod0026612025-05-08 13:00:36 +0000595 return (
596 <Comp
597 ref={ref}
598 data-sidebar="menu-sub-button"
599 data-size={size}
600 data-active={isActive}
601 className={cn(
602 "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground 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 [&>svg]:text-sidebar-accent-foreground",
603 "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
604 size === "sm" && "text-xs",
605 size === "md" && "text-sm",
606 "group-data-[collapsible=icon]:hidden",
607 className,
608 )}
609 {...props}
610 />
611 );
612});
613SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
gio5f2f1002025-03-20 18:38:48 +0400614
615export {
giod0026612025-05-08 13:00:36 +0000616 Sidebar,
617 SidebarContent,
618 SidebarFooter,
619 SidebarGroup,
620 SidebarGroupAction,
621 SidebarGroupContent,
622 SidebarGroupLabel,
623 SidebarHeader,
624 SidebarInput,
625 SidebarInset,
626 SidebarMenu,
627 SidebarMenuAction,
628 SidebarMenuBadge,
629 SidebarMenuButton,
630 SidebarMenuItem,
631 SidebarMenuSkeleton,
632 SidebarMenuSub,
633 SidebarMenuSubButton,
634 SidebarMenuSubItem,
635 SidebarProvider,
636 SidebarRail,
637 SidebarSeparator,
638 SidebarTrigger,
639 useSidebar,
640};