1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { cva, type VariantProps } from "class-variance-authority";
4import { PanelLeft, PanelLeftClose } from "lucide";
5import * as React from "react";
6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";
8import { Input, type InputProps } from "~/components/ui/input";
9import { MorphIcon } from "~/components/MorphIcon";
10import { ScrollArea } from "~/components/ui/scroll-area";
11import {
12 Sheet,
13 SheetDescription,
14 SheetHeader,
15 SheetPopup,
16 SheetTitle,
17} from "~/components/ui/sheet";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
19import { useResizeDrag } from "~/hooks/useResizeDrag";
20import { useIsMobile } from "~/hooks/useMediaQuery";
21import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
22import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
23import * as Schema from "effect/Schema";
24
25const SIDEBAR_COOKIE_NAME = "sidebar_state";
26const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
27const SIDEBAR_WIDTH = "16rem";
28const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
29const SIDEBAR_WIDTH_ICON = "3rem";
30const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
31const SIDEBAR_TITLEBAR_CONTROL_SIZE = "max-sm:[--workspace-titlebar-control-size:--spacing(8)]";
32
33type SidebarContextProps = {
34 state: ResponsiveSidebarState;
35 open: boolean;
36 setOpen: (open: boolean) => void;
37 openMobile: boolean;
38 mobileSheetMounted: boolean;
39 setMobileSheetElement: (element: HTMLDivElement | null) => void;
40 setOpenMobile: (open: boolean) => void;
41 isMobile: boolean;
42 toggleSidebar: () => void;
43};
44
45type SidebarResizableOptions = {
46 maxWidth?: number;
47 minWidth?: number;
48 onResize?: (width: number) => void;
49 shouldAcceptWidth?: (context: {
50 currentWidth: number;
51 nextWidth: number;
52 rail: HTMLButtonElement;
53 side: "left" | "right";
54 sidebarRoot: HTMLElement;
55 wrapper: HTMLElement;
56 }) => boolean;
57 storageKey?: string;
58};
59
60type SidebarResolvedResizableOptions = {
61 maxWidth: number;
62 minWidth: number;
63 onResize?: (width: number) => void;
64 shouldAcceptWidth?: (context: {
65 currentWidth: number;
66 nextWidth: number;
67 rail: HTMLButtonElement;
68 side: "left" | "right";
69 sidebarRoot: HTMLElement;
70 wrapper: HTMLElement;
71 }) => boolean;
72 storageKey: string | null;
73};
74
75type SidebarInstanceContextProps = {
76 resizable: SidebarResolvedResizableOptions | null;
77 side: "left" | "right";
78};
79
80const SidebarContext = React.createContext<SidebarContextProps | null>(null);
81const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
82
83function useSidebar() {
84 const context = React.use(SidebarContext);
85 if (!context) {
86 throw new Error("useSidebar must be used within a SidebarProvider.");
87 }
88
89 return context;
90}
91
92function useSidebarVisibility() {
93 const { isMobile, open, openMobile } = useSidebar();
94 return isMobile ? openMobile : open;
95}
96
97function SidebarProvider({
98 defaultOpen = true,
99 open: openProp,
100 onOpenChange: setOpenProp,
101 className,
102 style,
103 children,
104 ...props
105}: React.ComponentProps<"div"> & {
106 defaultOpen?: boolean;
107 open?: boolean;
108 onOpenChange?: (open: boolean) => void;
109}) {
110 const isMobile = useIsMobile();
111 const [openMobile, setOpenMobile] = React.useState(false);
112 const [mobileSheetMounted, setMobileSheetMounted] = React.useState(false);
113 const setMobileSheetElement = React.useCallback((element: HTMLDivElement | null) => {
114 setMobileSheetMounted(element !== null);
115 }, []);
116
117 // This is the internal state of the sidebar.
118 // We use openProp and setOpenProp for control from outside the component.
119 const [_open, _setOpen] = React.useState(defaultOpen);
120 const open = openProp ?? _open;
121 const setOpen = React.useCallback(
122 async (value: boolean | ((value: boolean) => boolean)) => {
123 const openState = typeof value === "function" ? value(open) : value;
124 if (setOpenProp) {
125 setOpenProp(openState);
126 } else {
127 _setOpen(openState);
128 }
129
130 // This sets the cookie to keep the sidebar state.
131 await cookieStore.set({
132 expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,
133 name: SIDEBAR_COOKIE_NAME,
134 path: "/",
135 value: String(openState),
136 });
137 },
138 [setOpenProp, open],
139 );
140
141 // Helper to toggle the sidebar.
142 const toggleSidebar = React.useCallback(() => {
143 return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
144 }, [isMobile, setOpen]);
145
146 // We add a state so that we can do data-state="expanded" or "collapsed".
147 // This makes it easier to style the sidebar with Tailwind classes.
148 const state = resolveSidebarState({ isMobile, open, openMobile });
149
150 const contextValue = React.useMemo<SidebarContextProps>(
151 () => ({
152 isMobile,
153 open,
154 openMobile,
155 mobileSheetMounted,
156 setMobileSheetElement,
157 setOpen,
158 setOpenMobile,
159 state,
160 toggleSidebar,
161 }),
162 [
163 state,
164 open,
165 setOpen,
166 isMobile,
167 openMobile,
168 mobileSheetMounted,
169 setMobileSheetElement,
170 toggleSidebar,
171 ],
172 );
173
174 return (
175 <SidebarContext value={contextValue}>
176 <div
177 // Inset layouts opt into bg-sidebar through className.
178 className={cn(
179 "group/sidebar-wrapper flex min-h-svh w-full",
180 SIDEBAR_TITLEBAR_CONTROL_SIZE,
181 className,
182 )}
183 data-sidebar-state={state}
184 data-slot="sidebar-wrapper"
185 style={
186 {
187 "--sidebar-width": SIDEBAR_WIDTH,
188 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
189 "--workspace-titlebar-content-left":
190 "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))",
191 ...style,
192 } as React.CSSProperties
193 }
194 {...props}
195 >
196 {children}
197 </div>
198 </SidebarContext>
199 );
200}
201
202function Sidebar({
203 side = "left",
204 variant = "sidebar",
205 collapsible = "offcanvas",
206 resizable = false,
207 className,
208 children,
209 ...props
210}: React.ComponentProps<"div"> & {
211 side?: "left" | "right";
212 variant?: "sidebar" | "floating" | "inset";
213 collapsible?: "offcanvas" | "icon" | "none";
214 resizable?: boolean | SidebarResizableOptions;
215}) {
216 const { isMobile, state, openMobile, setOpenMobile, setMobileSheetElement } = useSidebar();
217 const resolvedResizable = React.useMemo<SidebarResolvedResizableOptions | null>(() => {
218 if (isMobile || collapsible === "none" || !resizable) {
219 return null;
220 }
221
222 const options = typeof resizable === "boolean" ? {} : resizable;
223 return {
224 maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY,
225 minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH,
226 storageKey: options.storageKey ?? null,
227 ...(options.onResize ? { onResize: options.onResize } : {}),
228 ...(options.shouldAcceptWidth ? { shouldAcceptWidth: options.shouldAcceptWidth } : {}),
229 };
230 }, [collapsible, isMobile, resizable]);
231 const instanceContextValue = React.useMemo<SidebarInstanceContextProps>(
232 () => ({ side, resizable: resolvedResizable }),
233 [resolvedResizable, side],
234 );
235
236 if (collapsible === "none") {
237 return (
238 <SidebarInstanceContext value={instanceContextValue}>
239 <div
240 className={cn(
241 "flex h-full w-(--sidebar-width) flex-col bg-sidebar surface-grain text-sidebar-foreground",
242 className,
243 )}
244 data-slot="sidebar"
245 {...props}
246 >
247 {children}
248 </div>
249 </SidebarInstanceContext>
250 );
251 }
252
253 if (isMobile) {
254 return (
255 <SidebarInstanceContext value={instanceContextValue}>
256 <Sheet onOpenChange={setOpenMobile} open={openMobile}>
257 <SheetPopup
258 ref={setMobileSheetElement}
259 className={cn(
260 "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground",
261 className,
262 )}
263 data-mobile="true"
264 data-sidebar="sidebar"
265 data-slot="sidebar"
266 showCloseButton={false}
267 side={side}
268 style={
269 {
270 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
271 } as React.CSSProperties
272 }
273 >
274 <SheetHeader className="sr-only">
275 <SheetTitle>Sidebar</SheetTitle>
276 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
277 </SheetHeader>
278 <div
279 className={cn(
280 "flex h-full w-full flex-col pb-safe pt-safe",
281 SIDEBAR_TITLEBAR_CONTROL_SIZE,
282 side === "left" ? "pl-safe" : "pr-safe",
283 )}
284 {...props}
285 >
286 {children}
287 </div>
288 </SheetPopup>
289 </Sheet>
290 </SidebarInstanceContext>
291 );
292 }
293
294 return (
295 <SidebarInstanceContext value={instanceContextValue}>
296 <div
297 className="group peer hidden text-sidebar-foreground md:block"
298 data-collapsible={state === "collapsed" ? collapsible : ""}
299 data-side={side}
300 data-slot="sidebar"
301 data-state={state}
302 data-variant={variant}
303 >
304 {/* This is what handles the sidebar gap on desktop */}
305 <div
306 className={cn(
307 "relative w-(--sidebar-width) bg-transparent",
308 "[[data-panel-animations=true]_&]:transition-[width] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
309 "group-data-[collapsible=offcanvas]:w-0",
310 "group-data-[side=right]:rotate-180",
311 variant === "floating" || variant === "inset"
312 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
313 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
314 )}
315 data-slot="sidebar-gap"
316 />
317 <div
318 className={cn(
319 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
320 "[[data-panel-animations=true]_&]:transition-[left,right,width] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
321 side === "left"
322 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
323 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
324 // Adjust the padding for floating and inset variants.
325 variant === "floating" || variant === "inset"
326 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
327 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
328 className,
329 )}
330 data-slot="sidebar-container"
331 {...props}
332 >
333 <div
334 className={cn(
335 "flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5",
336 resolvedResizable && (side === "left" ? "pr-3" : "pl-3"),
337 )}
338 data-sidebar="sidebar"
339 data-slot="sidebar-inner"
340 >
341 {children}
342 </div>
343 </div>
344 </div>
345 </SidebarInstanceContext>
346 );
347}
348
349function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
350 const { toggleSidebar } = useSidebar();
351 const isOpen = useSidebarVisibility();
352
353 return (
354 <Button
355 className={cn(
356 "size-[var(--workspace-titlebar-control-size)]! [-webkit-app-region:no-drag]",
357 className,
358 )}
359 data-sidebar="trigger"
360 data-slot="sidebar-trigger"
361 aria-pressed={isOpen}
362 onClick={(event) => {
363 onClick?.(event);
364 toggleSidebar();
365 }}
366 size="icon"
367 variant="ghost"
368 {...props}
369 >
370 <MorphIcon className="size-4" icon={isOpen ? PanelLeftClose : PanelLeft} />
371 <span className="sr-only">Toggle Sidebar</span>
372 </Button>
373 );
374}
375
376function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number {
377 return Math.max(options.minWidth, Math.min(width, options.maxWidth));
378}
379
380function SidebarRail({
381 className,
382 onClick,
383 onPointerCancel,
384 onLostPointerCapture,
385 onPointerDown,
386 onPointerMove,
387 onPointerUp,
388 ...props
389}: React.ComponentProps<"button">) {
390 const { open, toggleSidebar } = useSidebar();
391 const sidebarInstance = React.use(SidebarInstanceContext);
392 const railRef = React.useRef<HTMLButtonElement | null>(null);
393 const suppressClickRef = React.useRef(false);
394 const resolvedResizable = sidebarInstance?.resizable ?? null;
395 const latestResizable = React.useRef(resolvedResizable);
396 const canResize = resolvedResizable !== null && open;
397 const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar";
398 const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar";
399 const resize = useResizeDrag<HTMLButtonElement>((event) => {
400 if (!resolvedResizable || !open) return null;
401 const rail = event.currentTarget;
402 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
403 const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']");
404 const sidebarContainer = sidebarRoot?.querySelector<HTMLElement>(
405 "[data-slot='sidebar-container']",
406 );
407 if (!wrapper || !sidebarRoot || !sidebarContainer) return null;
408
409 const side = sidebarInstance?.side ?? "left";
410 let width = clampSidebarWidth(
411 sidebarContainer.getBoundingClientRect().width,
412 resolvedResizable,
413 );
414 // Drag frames write width to the gap and container only. Changing the
415 // inherited --sidebar-width on the wrapper restyles the whole app, so it is
416 // written once when the drag commits.
417 const widthTargets = [
418 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
419 sidebarContainer,
420 ].filter((element): element is HTMLElement => element !== null);
421 const setTargetWidth = (value: number) => {
422 widthTargets.forEach((element) => {
423 element.style.setProperty("width", `${value}px`);
424 });
425 };
426 widthTargets.forEach((element) => {
427 element.style.setProperty("transition-duration", "0ms");
428 });
429 setTargetWidth(width);
430
431 return {
432 size: width,
433 edge: side === "left" ? "right" : "left",
434 resize(value) {
435 const options = latestResizable.current;
436 if (!options) return width;
437 const nextWidth = clampSidebarWidth(value, options);
438 const accepted =
439 options.shouldAcceptWidth?.({
440 currentWidth: width,
441 nextWidth,
442 rail,
443 side,
444 sidebarRoot,
445 wrapper,
446 }) ?? true;
447 if (accepted) {
448 setTargetWidth(nextWidth);
449 width = nextWidth;
450 }
451 return width;
452 },
453 finish(finalWidth, moved) {
454 wrapper.style.setProperty("--sidebar-width", `${finalWidth}px`);
455 suppressClickRef.current = moved;
456 const options = latestResizable.current;
457 if (options?.storageKey) {
458 try {
459 setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite);
460 } catch (error) {
461 console.error("Could not persist sidebar width.", error);
462 }
463 }
464 options?.onResize?.(finalWidth);
465 },
466 cleanup() {
467 widthTargets.forEach((element) => {
468 element.style.removeProperty("width");
469 element.style.removeProperty("transition-duration");
470 });
471 },
472 };
473 // Toggling the sidebar cancels a drag so the inline width never pins a
474 // collapsed sidebar open.
475 }, String(open));
476 React.useLayoutEffect(() => {
477 latestResizable.current = resolvedResizable;
478 // Bounds follow the window; keep an active drag's inline width inside them.
479 resize.refresh();
480 }, [resolvedResizable]);
481
482 const handleClick = React.useCallback(
483 (event: React.MouseEvent<HTMLButtonElement>) => {
484 onClick?.(event);
485 if (event.defaultPrevented) return;
486 if (suppressClickRef.current) {
487 suppressClickRef.current = false;
488 event.preventDefault();
489 return;
490 }
491 if (resolvedResizable && open) {
492 event.preventDefault();
493 return;
494 }
495 toggleSidebar();
496 },
497 [onClick, open, resolvedResizable, toggleSidebar],
498 );
499
500 React.useLayoutEffect(() => {
501 if (!resolvedResizable?.storageKey || typeof window === "undefined") return;
502 const rail = railRef.current;
503 if (!rail) return;
504 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
505 if (!wrapper) return;
506
507 let storedWidth: number | null;
508 try {
509 storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite);
510 } catch (error) {
511 console.error("Could not restore persisted sidebar width.", error);
512 return;
513 }
514 if (storedWidth === null) return;
515 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);
516 // Hydrate the CSS variable before the browser paints so a restored sidebar
517 // never flashes at the default width first.
518 wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`);
519 resolvedResizable.onResize?.(clampedWidth);
520 }, [resolvedResizable]);
521
522 return (
523 <Tooltip>
524 <TooltipTrigger
525 render={
526 <button
527 aria-label={railLabel}
528 className={cn(
529 "group absolute inset-y-0 z-20 hidden w-3 touch-none select-none outline-none sm:flex hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15 [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
530 "group-data-[side=left]:right-0 group-data-[side=right]:left-0 cursor-col-resize",
531 "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border/50 group-data-[side=left]:after:right-0 group-data-[side=right]:after:left-0",
532 className,
533 )}
534 data-sidebar="rail"
535 data-slot="sidebar-rail"
536 onClick={handleClick}
537 onLostPointerCapture={(event) => {
538 onLostPointerCapture?.(event);
539 if (!event.defaultPrevented) resize.onLostPointerCapture(event);
540 }}
541 onPointerCancel={(event) => {
542 onPointerCancel?.(event);
543 if (!event.defaultPrevented) resize.onPointerCancel(event);
544 }}
545 onPointerDown={(event) => {
546 onPointerDown?.(event);
547 if (!event.defaultPrevented) resize.onPointerDown(event);
548 }}
549 onPointerMove={(event) => {
550 onPointerMove?.(event);
551 if (!event.defaultPrevented) resize.onPointerMove(event);
552 }}
553 onPointerUp={(event) => {
554 onPointerUp?.(event);
555 if (!event.defaultPrevented) resize.onPointerUp(event);
556 }}
557 ref={railRef}
558 role={canResize ? "separator" : undefined}
559 aria-orientation={canResize ? "vertical" : undefined}
560 aria-valuemin={canResize ? resolvedResizable.minWidth : undefined}
561 aria-valuemax={
562 canResize && Number.isFinite(resolvedResizable.maxWidth)
563 ? resolvedResizable.maxWidth
564 : undefined
565 }
566 onKeyDown={(event) => {
567 if (!canResize) return;
568 const rail = event.currentTarget;
569 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
570 const container = rail.closest<HTMLElement>("[data-slot='sidebar-container']");
571 if (!wrapper || !container) return;
572 const currentWidth = container.getBoundingClientRect().width;
573 const side = sidebarInstance?.side ?? "left";
574 const step = (event.shiftKey ? 40 : 10) * (side === "left" ? 1 : -1);
575 const next =
576 event.key === "ArrowRight"
577 ? currentWidth + step
578 : event.key === "ArrowLeft"
579 ? currentWidth - step
580 : event.key === "Home"
581 ? resolvedResizable.minWidth
582 : event.key === "End" && Number.isFinite(resolvedResizable.maxWidth)
583 ? resolvedResizable.maxWidth
584 : null;
585 if (next === null) return;
586 event.preventDefault();
587 const nextWidth = clampSidebarWidth(next, resolvedResizable);
588 const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']");
589 if (
590 !sidebarRoot ||
591 resolvedResizable.shouldAcceptWidth?.({
592 currentWidth,
593 nextWidth,
594 wrapper,
595 rail,
596 side,
597 sidebarRoot,
598 }) === false
599 )
600 return;
601 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
602 if (resolvedResizable.storageKey)
603 setLocalStorageItem(resolvedResizable.storageKey, nextWidth, Schema.Finite);
604 resolvedResizable.onResize?.(nextWidth);
605 }}
606 tabIndex={canResize ? 0 : -1}
607 type="button"
608 {...props}
609 />
610 }
611 />
612 <TooltipPopup side="right">{railTitle}</TooltipPopup>
613 </Tooltip>
614 );
615}
616
617/** A bare search or rename field that sits in a row of sidebar chrome. */
618function SidebarInput({ className, ...props }: Omit<InputProps, "unstyled" | "variant">) {
619 return (
620 <Input
621 className={cn(
622 "[&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground",
623 className,
624 )}
625 unstyled
626 {...props}
627 />
628 );
629}
630
631function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
632 return (
633 <main
634 className={cn(
635 "relative flex min-w-0 w-full flex-1 flex-col bg-background surface-grain",
636 "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5",
637 className,
638 )}
639 data-slot="sidebar-inset"
640 {...props}
641 />
642 );
643}
644
645function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
646 return (
647 <div
648 className={cn("flex flex-col gap-2 p-2", className)}
649 data-sidebar="header"
650 data-slot="sidebar-header"
651 {...props}
652 />
653 );
654}
655
656function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
657 return (
658 <div
659 className={cn("flex flex-col gap-2 px-[var(--sidebar-content-inset)] py-1", className)}
660 data-sidebar="footer"
661 data-slot="sidebar-footer"
662 {...props}
663 />
664 );
665}
666
667function SidebarContent({
668 className,
669 fixedHeader,
670 ...props
671}: React.ComponentProps<"div"> & {
672 fixedHeader?: React.ReactNode;
673}) {
674 return (
675 <>
676 {fixedHeader ? <div className="w-full shrink-0">{fixedHeader}</div> : null}
677 {/* Rows take focus on click. Scroll padding would make the browser nudge
678 the list whenever a focused row sits under the fade. */}
679 <ScrollArea
680 hideScrollbars
681 scrollFade
682 scrollFadePadding={false}
683 // Thread rows provide keyboard access to this scroll region. Keeping
684 // Base UI's viewport out of the tab order lets its presentational role
685 // flatten in WebKit instead of becoming a VoiceOver interaction group.
686 viewportTabIndex={-1}
687 className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
688 >
689 <div
690 // Reordered rows must not pull the viewport to their new position.
691 className={cn(
692 // Stacked groups share one inset between them instead of doubling
693 // it, including across the fixed header's boundary.
694 "flex w-full min-w-0 flex-col [overflow-anchor:none] group-data-[collapsible=icon]:overflow-hidden [&>[data-sidebar=group]+[data-sidebar=group]]:pt-0",
695 fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0",
696 className,
697 )}
698 data-sidebar="content"
699 data-slot="sidebar-content"
700 {...props}
701 />
702 </ScrollArea>
703 </>
704 );
705}
706
707function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
708 return (
709 <div
710 className={cn(
711 "relative flex w-full min-w-0 flex-col p-[var(--sidebar-content-inset)]",
712 className,
713 )}
714 data-sidebar="group"
715 data-slot="sidebar-group"
716 {...props}
717 />
718 );
719}
720
721function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
722 return (
723 <ul
724 className={cn("flex w-full min-w-0 flex-col gap-1", className)}
725 data-sidebar="menu"
726 data-slot="sidebar-menu"
727 {...props}
728 />
729 );
730}
731
732function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
733 return (
734 <li
735 className={cn("group/menu-item relative", className)}
736 data-sidebar="menu-item"
737 data-slot="sidebar-menu-item"
738 {...props}
739 />
740 );
741}
742
743const sidebarMenuButtonVariants = cva(
744 "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-inset active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground",
745 {
746 defaultVariants: {
747 size: "default",
748 variant: "default",
749 },
750 variants: {
751 size: {
752 default:
753 "h-8 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-1.5 text-sm",
754 icon: "size-8 justify-center rounded-[var(--control-radius)] p-0",
755 lg: "h-12 rounded-lg p-2 text-sm group-data-[collapsible=icon]:p-0!",
756 sm: "h-7 rounded-lg p-2 text-xs",
757 },
758 variant: {
759 default: "font-medium text-sidebar-muted-foreground/80",
760 outline: "bg-sidebar-control-surface ring-1 ring-sidebar-border",
761 },
762 },
763 },
764);
765
766function SidebarMenuButton({
767 isActive = false,
768 variant = "default",
769 size = "default",
770 tooltip,
771 className,
772 render,
773 ...props
774}: useRender.ComponentProps<"button"> & {
775 isActive?: boolean;
776 tooltip?: string | React.ComponentProps<typeof TooltipPopup>;
777} & VariantProps<typeof sidebarMenuButtonVariants>) {
778 const { isMobile, state } = useSidebar();
779
780 const defaultProps = {
781 className: cn(sidebarMenuButtonVariants({ size, variant }), className),
782 "data-active": isActive,
783 "data-sidebar": "menu-button",
784 "data-size": size,
785 "data-slot": "sidebar-menu-button",
786 };
787
788 const buttonProps = mergeProps<"button">(defaultProps, props);
789
790 const buttonElement = useRender({
791 defaultTagName: "button",
792 props: buttonProps,
793 render,
794 });
795
796 if (!tooltip) {
797 return buttonElement;
798 }
799
800 if (typeof tooltip === "string") {
801 tooltip = {
802 children: tooltip,
803 };
804 }
805
806 return (
807 <Tooltip>
808 <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} />
809 <TooltipPopup
810 align="center"
811 hidden={state !== "collapsed" || isMobile}
812 side="right"
813 {...tooltip}
814 />
815 </Tooltip>
816 );
817}
818
819function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
820 return (
821 <ul
822 className={cn(
823 "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
824 "group-data-[collapsible=icon]:hidden",
825 className,
826 )}
827 data-sidebar="menu-sub"
828 data-slot="sidebar-menu-sub"
829 {...props}
830 />
831 );
832}
833
834function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
835 return (
836 <li
837 className={cn("group/menu-sub-item relative", className)}
838 data-sidebar="menu-sub-item"
839 data-slot="sidebar-menu-sub-item"
840 {...props}
841 />
842 );
843}
844
845function SidebarMenuSubButton({
846 size = "md",
847 isActive = false,
848 className,
849 render,
850 ...props
851}: useRender.ComponentProps<"a"> & {
852 size?: "sm" | "md";
853 isActive?: boolean;
854}) {
855 const defaultProps = {
856 className: cn(
857 "-translate-x-px flex h-7 min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-ring hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-inset active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground",
858 "data-[active=true]:bg-sidebar-row-selected data-[active=true]:text-sidebar-foreground",
859 size === "sm" && "text-xs",
860 size === "md" && "text-sm",
861 "group-data-[collapsible=icon]:hidden",
862 className,
863 ),
864 "data-active": isActive,
865 "data-sidebar": "menu-sub-button",
866 "data-size": size,
867 "data-slot": "sidebar-menu-sub-button",
868 };
869
870 return useRender({
871 defaultTagName: "a",
872 props: mergeProps<"a">(defaultProps, props),
873 render,
874 });
875}
876
877export {
878 SidebarInput,
879 Sidebar,
880 SidebarContent,
881 SidebarFooter,
882 SidebarGroup,
883 SidebarHeader,
884 SidebarInset,
885 SidebarMenu,
886 SidebarMenuButton,
887 SidebarMenuItem,
888 SidebarMenuSub,
889 SidebarMenuSubButton,
890 SidebarMenuSubItem,
891 SidebarProvider,
892 SidebarRail,
893 SidebarTrigger,
894 useSidebar,
895 useSidebarVisibility,
896};