Skip to content
Source
Sign inSign up
Open History to see the last commit

sidebar.tsx

1,129 lines38.6 KB

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 { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
5import * as React from "react";
6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";
8import { ScrollArea } from "~/components/ui/scroll-area";
9import {
10 Sheet,
11 SheetDescription,
12 SheetHeader,
13 SheetPopup,
14 SheetTitle,
15} from "~/components/ui/sheet";
16import {
17 Tooltip,
18 TooltipPopup,
19 TooltipTrigger,
20 TooltipScrollDismissArea,
21} from "~/components/ui/tooltip";
22import { useResizeDrag } from "~/hooks/useResizeDrag";
23import { useMediaQuery } from "~/hooks/useMediaQuery";
24import { useClientSettings } from "~/hooks/useSettings";
25import {
26 getLocalStorageItem,
27 setLocalStorageItem,
28 writeLocalStorageValue,
29} from "~/hooks/useLocalStorage";
30import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState";
31import * as Schema from "effect/Schema";
32import { announceSidebarVisibility } from "~/lib/accessibilityAnnouncements";
33​
34const SIDEBAR_WIDTH = "16rem";
35const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing) * 3)";
36const SIDEBAR_WIDTH_ICON = "3rem";
37const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
38const SIDEBAR_TITLEBAR_CONTROL_SIZE = "max-sm:[--workspace-titlebar-control-size:--spacing(8)]";
39​
40type SidebarContextProps = {
41 state: ResponsiveSidebarState;
42 open: boolean;
43 setOpen: (open: boolean) => void;
44 openMobile: boolean;
45 mobileSheetMounted: boolean;
46 setMobileSheetMounted: (mounted: boolean) => void;
47 setOpenMobile: (open: boolean) => void;
48 isMobile: boolean;
49 toggleSidebar: () => void;
50 /** Runs before the desktop sidebar collapses. */
51 onCollapse: (listener: () => void) => () => void;
52};
53​
54type SidebarResizableOptions = {
55 maxWidth?: number;
56 minWidth?: number;
57 onResize?: (width: number) => void;
58 shouldAcceptWidth?: (context: {
59 currentWidth: number;
60 nextWidth: number;
61 rail: HTMLButtonElement;
62 side: "left" | "right";
63 sidebarRoot: HTMLElement;
64 wrapper: HTMLElement;
65 }) => boolean;
66 storageKey?: string;
67};
68​
69type SidebarResolvedResizableOptions = {
70 maxWidth: number;
71 minWidth: number;
72 onResize?: (width: number) => void;
73 shouldAcceptWidth?: (context: {
74 currentWidth: number;
75 nextWidth: number;
76 rail: HTMLButtonElement;
77 side: "left" | "right";
78 sidebarRoot: HTMLElement;
79 wrapper: HTMLElement;
80 }) => boolean;
81 storageKey: string | null;
82};
83​
84type SidebarInstanceContextProps = {
85 resizable: SidebarResolvedResizableOptions | null;
86 side: "left" | "right";
87};
88​
89const SidebarContext = React.createContext<SidebarContextProps | null>(null);
90const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
91​
92function useSidebar() {
93 const context = React.use(SidebarContext);
94 if (!context) {
95 throw new Error("useSidebar must be used within a SidebarProvider.");
96 }
97​
98 return context;
100​
101function useSidebarVisibility() {
102 const { isMobile, open, openMobile } = useSidebar();
103 return isMobile ? openMobile : open;
105​
106/** Whether the mobile sidebar sheet covers the page; false outside a SidebarProvider. */
107function useMobileSidebarSheetMounted() {
108 const context = React.use(SidebarContext);
109 return context !== null && context.isMobile && context.mobileSheetMounted;
111​
112// The `max-md` breakpoint, also read when a shortcut fires.
113const SIDEBAR_MOBILE_QUERY = "(max-width: 767px)";
114​
115function isMobileViewport(): boolean {
116 return typeof window !== "undefined" && window.matchMedia(SIDEBAR_MOBILE_QUERY).matches;
118​
119function readStoredSidebarOpen(storageKey: string | undefined): boolean | null {
120 if (!storageKey) return null;
121 try {
122 return getLocalStorageItem(storageKey, Schema.Boolean);
123 } catch (error) {
124 console.error("Could not restore persisted sidebar state.", error);
125 return null;
126 }
128​
129// An offcanvas sidebar turns inert once collapsed, which drops focus to the body.
130function moveFocusOutOfCollapsingSidebar() {
131 const sidebar = document.activeElement?.closest<HTMLElement>(
132 '[data-slot="sidebar"][data-collapsible-mode="offcanvas"]',
133 );
134 const wrapper = sidebar?.closest<HTMLElement>('[data-slot="sidebar-wrapper"]');
135 if (!sidebar || !wrapper) return;
136 const trigger = Array.from(
137 wrapper.querySelectorAll<HTMLElement>('[data-sidebar="trigger"]'),
138 ).find((element) => !sidebar.contains(element));
139 trigger?.focus({ preventScroll: true });
141​
142const SIDEBAR_SLIDE_ID = "sidebar-slide";
143​
144function sidebarSlideTargets() {
145 const scope = '[data-panel-animations="true"]';
146 const containers = document.querySelectorAll<HTMLElement>(
147 `${scope} [data-slot="sidebar-container"]`,
148 );
149 const insets = document.querySelectorAll<HTMLElement>(`${scope} [data-slot="sidebar-inset"]`);
150 return [
151 ...Array.from(containers, (element) => ({ element, parent: null, inset: false })),
152 ...Array.from(insets).flatMap((inset) => [
153 { element: inset, parent: null, inset: true },
154 ...Array.from(
155 inset.querySelectorAll<HTMLElement>("[data-sidebar-slide-children] > *"),
156 (element) => ({ element, parent: inset, inset: false }),
157 ),
158 ]),
159 ].map((target) => ({ ...target, start: target.element.getBoundingClientRect().left }));
161​
162function sidebarSlideAnchors(insets: HTMLElement[]) {
163 return insets.flatMap((inset) => [
164 ...Array.from(inset.querySelectorAll<HTMLElement>("[data-sidebar-slide-hold]"), (element) => ({
165 element,
166 inset,
167 via: null,
168 })),
169 ...Array.from(
170 inset.querySelectorAll<HTMLElement>(
171 "[data-sidebar-slide-children] > * [data-sidebar-slide-stay]",
172 ),
173 (element) => ({
174 element,
175 inset,
176 via: element.closest<HTMLElement>("[data-sidebar-slide-children] > *"),
177 }),
178 ),
179 ]);
181​
182function siblingSlot(element: Element | null, slot: string): HTMLElement | null {
183 return element instanceof HTMLElement && element.dataset.slot === slot ? element : null;
185​
186function sidebarInsetEdgeRows(inset: HTMLElement, rect: DOMRect, underNotch: boolean) {
187 const rows = Array.from(inset.querySelectorAll<HTMLElement>("[data-layout-pane-id]")).flatMap(
188 (pane) => {
189 const paneRect = pane.getBoundingClientRect();
190 if (paneRect.width === 0 || paneRect.right < rect.right - 1 || paneRect.left >= rect.right) {
191 return [];
192 }
193 const notch = underNotch
194 ? pane.querySelector<HTMLElement>('[data-pane-notch="right"]')
195 : null;
196 const top = notch?.offsetWidth ? notch.getBoundingClientRect().bottom : paneRect.top;
197 return [{ top: Math.max(top, rect.top), bottom: Math.min(paneRect.bottom, rect.bottom) }];
198 },
199 );
200 return rows.length > 0 ? rows : [{ top: rect.top, bottom: rect.bottom }];
202​
203function fillSidebarInsetEdge(
204 inset: HTMLElement,
205 offset: number,
206 timing: KeyframeAnimationOptions,
207) {
208 const opening = offset < 0;
209 const edge = opening
210 ? siblingSlot(inset.previousElementSibling, "sidebar-slide-surface")
211 : siblingSlot(inset.nextElementSibling, "sidebar-slide-frame");
212 if (!edge) return;
213 const rect = inset.getBoundingClientRect();
214 edge.style.left = `${opening ? rect.right + offset : rect.right}px`;
215 edge.style.width = `${opening ? -offset : window.innerWidth - rect.right}px`;
216 edge.replaceChildren(
217 ...sidebarInsetEdgeRows(inset, rect, opening).flatMap(({ top, bottom }) => {
218 if (opening) {
219 const piece = document.createElement("div");
220 piece.className = "absolute inset-x-0 rounded-pane bg-background";
221 Object.assign(piece.style, { top: `${top}px`, height: `${bottom - top}px` });
222 return [piece];
223 }
224 return (["top", "bottom"] as const).map((corner) => {
225 const piece = document.createElement("div");
226 piece.dataset.corner = corner;
227 piece.style.top = `${corner === "top" ? top : bottom}px`;
228 return piece;
229 });
230 }),
231 );
232 edge.animate({ opacity: [1, 1] }, timing);
234​
235function cancelSidebarSlide(elements: Iterable<Element | null>) {
236 for (const element of elements) {
237 for (const animation of element?.getAnimations() ?? []) {
238 if (animation.id === SIDEBAR_SLIDE_ID) animation.cancel();
239 }
240 }
242​
243let sidebarSlidePending = false;
244​
245function slideSidebar(commit: () => void) {
246 if (sidebarSlidePending) {
247 commit();
248 return;
249 }
250 const targets = sidebarSlideTargets();
251 commit();
252 const first = targets[0];
253 if (!first) return;
254 sidebarSlidePending = true;
255 window.requestAnimationFrame(() => {
256 sidebarSlidePending = false;
257 const insets = targets.filter(({ inset }) => inset).map(({ element }) => element);
258 const anchors = sidebarSlideAnchors(insets);
259 cancelSidebarSlide([
260 ...targets.map(({ element }) => element),
261 ...anchors.map(({ element }) => element),
262 ...insets.flatMap((inset) => [inset.previousElementSibling, inset.nextElementSibling]),
263 ]);
264 const offsets = new Map(
265 targets.map(({ element, start }) => [element, start - element.getBoundingClientRect().left]),
266 );
267 const anchorStarts = anchors.map(({ element }) => element.getBoundingClientRect());
268 const duration = Number.parseFloat(
269 getComputedStyle(first.element).getPropertyValue("--panel-animation-duration"),
270 );
271 const timing: KeyframeAnimationOptions = {
272 duration: Number.isFinite(duration) ? duration : 0,
273 easing: "ease-out",
274 id: SIDEBAR_SLIDE_ID,
275 };
276 const slides = new Map<HTMLElement, number>();
277 for (const { element, parent, inset } of targets) {
278 const ownOffset = offsets.get(element) ?? 0;
279 if (parent && Math.abs(ownOffset) < 0.5) continue;
280 const offset = ownOffset - (parent ? (offsets.get(parent) ?? 0) : 0);
281 if (!element.isConnected || Math.abs(offset) < 0.5) continue;
282 if (inset) fillSidebarInsetEdge(element, offset, timing);
283 element.animate({ transform: [`translateX(${offset}px)`, "translateX(0)"] }, timing);
284 slides.set(element, offset);
285 }
286 anchors.forEach(({ element, inset, via }, index) => {
287 const start = anchorStarts[index];
288 const insetOffset = slides.get(inset) ?? 0;
289 const viaOffset = via ? (slides.get(via) ?? 0) : 0;
290 if (!start || (via ? viaOffset === 0 : insetOffset === 0)) return;
291 const contained = element.getBoundingClientRect();
292 const x = start.left - contained.left;
293 const y = start.top - contained.top;
294 const from = via ? x + insetOffset : x;
295 element.animate(
296 {
297 transform: [
298 `translate(${from}px, ${y}px)`,
299 `translate(${x + insetOffset + viaOffset}px, ${y}px)`,
300 ],
301 },
302 timing,
303 );
304 });
305 });
307​
308interface SidebarOpenState {
309 readonly open: boolean;
310 readonly openMobile: boolean;
312​
313/** Per-provider open state, kept outside React so shortcuts can toggle it at event time. */
314function createSidebarOpenStore(defaultOpen: boolean, storageKey: string | undefined) {
315 let state: SidebarOpenState = {
316 open: readStoredSidebarOpen(storageKey) ?? defaultOpen,
317 openMobile: false,
318 };
319 const listeners = new Set<() => void>();
320 const collapseListeners = new Set<() => void>();
321 const publish = (next: SidebarOpenState) => {
322 state = next;
323 for (const listener of listeners) listener();
324 };
325 const setOpen = (open: boolean) => {
326 if (!open && state.open) {
327 for (const listener of collapseListeners) listener();
328 moveFocusOutOfCollapsingSidebar();
329 }
330 if (open !== state.open) {
331 if (!isMobileViewport()) announceSidebarVisibility(open);
332 slideSidebar(() => publish({ ...state, open }));
333 }
334 if (storageKey) writeLocalStorageValue(storageKey, open, defaultOpen, Schema.Boolean);
335 };
336 const setOpenMobile = (openMobile: boolean) => {
337 if (openMobile !== state.openMobile) publish({ ...state, openMobile });
338 };
339 return {
340 subscribe: (listener: () => void) => {
341 listeners.add(listener);
342 return () => {
343 listeners.delete(listener);
344 };
345 },
346 getSnapshot: () => state,
347 setOpen,
348 setOpenMobile,
349 toggle: () => (isMobileViewport() ? setOpenMobile(!state.openMobile) : setOpen(!state.open)),
350 onCollapse: (listener: () => void) => {
351 collapseListeners.add(listener);
352 return () => {
353 collapseListeners.delete(listener);
354 };
355 },
356 };
358​
359function SidebarProvider({
360 defaultOpen = true,
361 storageKey,
362 store: providedStore,
363 className,
364 style,
365 children,
366 ...props
367}: React.ComponentProps<"div"> & {
368 defaultOpen?: boolean;
369 storageKey?: string;
370 store?: ReturnType<typeof createSidebarOpenStore>;
371}) {
372 const isMobile = useMediaQuery(SIDEBAR_MOBILE_QUERY);
373 const [store] = React.useState(
374 () => providedStore ?? createSidebarOpenStore(defaultOpen, storageKey),
375 );
376 const { open, openMobile } = React.useSyncExternalStore(
377 store.subscribe,
378 store.getSnapshot,
379 store.getSnapshot,
380 );
381 const [mobileSheetMounted, setMobileSheetMounted] = React.useState(false);
382 if (openMobile && !mobileSheetMounted) {
383 setMobileSheetMounted(true);
384 }
385 // Widening mid-close unmounts the sheet before its close completes.
386 if (!isMobile && !openMobile && mobileSheetMounted) {
387 setMobileSheetMounted(false);
388 }
389​
390 const state = resolveSidebarState({ isMobile, open, openMobile });
391​
392 const contextValue: SidebarContextProps = {
393 isMobile,
394 open,
395 openMobile,
396 mobileSheetMounted,
397 setMobileSheetMounted,
398 setOpen: store.setOpen,
399 setOpenMobile: store.setOpenMobile,
400 state,
401 toggleSidebar: store.toggle,
402 onCollapse: store.onCollapse,
403 };
404​
405 return (
406 <SidebarContext value={contextValue}>
407 <div
408 className={cn(
409 "group/sidebar-wrapper flex min-h-svh w-full",
410 SIDEBAR_TITLEBAR_CONTROL_SIZE,
411 className,
412 )}
413 data-sidebar-state={state}
414 data-slot="sidebar-wrapper"
415 style={
416 {
417 "--sidebar-width": SIDEBAR_WIDTH,
418 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
419 "--workspace-titlebar-content-left":
420 "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))",
421 ...style,
422 } as React.CSSProperties
423 }
424 {...props}
425 >
426 {children}
427 </div>
428 </SidebarContext>
429 );
431​
432function resolveSidebarResizable({
433 collapsible,
434 isMobile,
435 resizable,
436}: {
437 collapsible: "offcanvas" | "icon" | "none";
438 isMobile: boolean;
439 resizable: boolean | SidebarResizableOptions;
440}): SidebarResolvedResizableOptions | null {
441 if (isMobile || collapsible === "none" || !resizable) {
442 return null;
443 }
444​
445 const options = typeof resizable === "boolean" ? {} : resizable;
446 return {
447 maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY,
448 minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH,
449 storageKey: options.storageKey ?? null,
450 ...(options.onResize ? { onResize: options.onResize } : {}),
451 ...(options.shouldAcceptWidth ? { shouldAcceptWidth: options.shouldAcceptWidth } : {}),
452 };
454​
455function Sidebar({
456 side = "left",
457 variant = "sidebar",
458 collapsible = "offcanvas",
459 resizable = false,
460 className,
461 children,
462 ...props
463}: React.ComponentProps<"div"> & {
464 side?: "left" | "right";
465 variant?: "sidebar" | "floating" | "inset";
466 collapsible?: "offcanvas" | "icon" | "none";
467 resizable?: boolean | SidebarResizableOptions;
468}) {
469 const { isMobile, state, openMobile, setOpenMobile, setMobileSheetMounted } = useSidebar();
470 // Not usePanelAnimationSettings: its navigation suppression would snap the sheet shut mid-exit when an item navigates.
471 const panelAnimationDurationMs = useClientSettings(
472 (settings) => settings.panelAnimationDurationMs,
473 );
474 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
475 const panelAnimationsActive = panelAnimationDurationMs > 0 && !prefersReducedMotion;
476 const offcanvasCollapsed = !isMobile && collapsible === "offcanvas" && state === "collapsed";
477​
478 const resolvedResizable = resolveSidebarResizable({ collapsible, isMobile, resizable });
479 const instanceContextValue: SidebarInstanceContextProps = { side, resizable: resolvedResizable };
480​
481 if (collapsible === "none") {
482 return (
483 <SidebarInstanceContext value={instanceContextValue}>
484 <div
485 className={cn(
486 "flex h-full w-(--sidebar-width) flex-col bg-sidebar surface-grain text-sidebar-foreground",
487 className,
488 )}
489 data-slot="sidebar"
490 {...props}
491 >
492 {children}
493 </div>
494 </SidebarInstanceContext>
495 );
496 }
497​
498 if (isMobile) {
499 return (
500 <SidebarInstanceContext value={instanceContextValue}>
501 <Sheet
502 onOpenChange={setOpenMobile}
503 onOpenChangeComplete={(open) => {
504 if (!open) setMobileSheetMounted(false);
505 }}
506 open={openMobile}
507 >
508 {/* Children stay mounted while closed so their window shortcuts, like thread jumps, keep working. */}
509 <SheetPopup
510 keepMounted
511 {...(panelAnimationsActive ? {} : { transitionDurationMs: 0 })}
512 className={cn(
513 "w-(--sidebar-width) max-w-none bg-(--app-chrome-background) bg-(image:--surface-grain) p-0 surface-grain text-sidebar-foreground [.black-chrome_&]:[--kbd-background:initial]",
514 className,
515 )}
516 data-mobile="true"
517 data-sidebar="sidebar"
518 data-slot="sidebar"
519 showCloseButton={false}
520 side={side}
521 style={
522 {
523 "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
524 } as React.CSSProperties
525 }
526 >
527 <SheetHeader className="sr-only">
528 <SheetTitle>{props["aria-label"] ?? "Sidebar"}</SheetTitle>
529 <SheetDescription>Displays the mobile sidebar.</SheetDescription>
530 </SheetHeader>
531 <div
532 className={cn(
533 "flex h-full w-full flex-col pb-safe pt-safe",
534 SIDEBAR_TITLEBAR_CONTROL_SIZE,
535 side === "left" ? "pl-safe" : "pr-safe",
536 )}
537 {...props}
538 >
539 {children}
540 </div>
541 </SheetPopup>
542 </Sheet>
543 </SidebarInstanceContext>
544 );
545 }
546​
547 return (
548 <SidebarInstanceContext value={instanceContextValue}>
549 <div
550 className="group peer hidden text-sidebar-foreground md:block"
551 data-collapsible={state === "collapsed" ? collapsible : ""}
552 data-collapsible-mode={collapsible}
553 inert={offcanvasCollapsed}
554 data-side={side}
555 data-slot="sidebar"
556 data-state={state}
557 data-variant={variant}
558 >
559 <div
560 className={cn(
561 "relative w-(--sidebar-width) bg-transparent",
562 "group-data-[collapsible=offcanvas]:w-0",
563 "group-data-[side=right]:rotate-180",
564 variant === "floating"
565 ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
566 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
567 )}
568 data-slot="sidebar-gap"
569 />
570 <div
571 className={cn(
572 "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
573 side === "left"
574 ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
575 : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
576 // Floating pads its card; inset sits flush on the window chrome
577 // beside the inset content pane, so it has no edge border.
578 variant === "floating"
579 ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
580 : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
581 variant === "sidebar" &&
582 "group-data-[side=left]:border-r group-data-[side=right]:border-l",
583 className,
584 )}
585 data-slot="sidebar-container"
586 {...props}
587 >
588 <div
589 className="flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=inset]:ps-(--workspace-sidebar-edge-inset) 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"
590 data-sidebar="sidebar"
591 data-slot="sidebar-inner"
592 >
593 {children}
594 </div>
595 </div>
596 </div>
597 </SidebarInstanceContext>
598 );
600​
601function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
602 const { toggleSidebar } = useSidebar();
603 const isOpen = useSidebarVisibility();
604​
605 return (
606 <Button
607 className={cn(
608 "size-[var(--workspace-titlebar-control-size)]! [-webkit-app-region:no-drag]",
609 className,
610 )}
611 data-sidebar="trigger"
612 data-slot="sidebar-trigger"
613 aria-pressed={isOpen}
614 onClick={(event) => {
615 onClick?.(event);
616 toggleSidebar();
617 }}
618 size="icon"
619 variant="ghost"
620 {...props}
621 >
622 {isOpen ? (
623 <PanelLeftCloseIcon
624 className={cn(
625 "size-4",
626 props.variant === "media-navigation" ? "text-current" : "text-sidebar-toggle",
627 )}
628​
629 strokeWidth={2.25}
630 />
631 ) : (
632 <PanelLeftIcon
633 className={cn(
634 "size-4",
635 props.variant === "media-navigation" ? "text-current" : "text-sidebar-toggle",
636 )}
637​
638 strokeWidth={2.25}
639 />
640 )}
641 <span className="sr-only">Toggle Sidebar</span>
642 </Button>
643 );
645​
646function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number {
647 return Math.max(options.minWidth, Math.min(width, options.maxWidth));
649​
650function SidebarRail({
651 className,
652 onClick,
653 onPointerCancel,
654 onLostPointerCapture,
655 onPointerDown,
656 onPointerMove,
657 onPointerUp,
658 ...props
659}: React.ComponentProps<"button">) {
660 const { open, toggleSidebar, onCollapse } = useSidebar();
661 const sidebarInstance = React.use(SidebarInstanceContext);
662 const suppressClickRef = React.useRef(false);
663 const resolvedResizable = sidebarInstance?.resizable ?? null;
664 const canResize = resolvedResizable !== null && open;
665 const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar";
666 const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar";
667 const resize = useResizeDrag<HTMLButtonElement>((event) => {
668 const options = resolvedResizable;
669 if (!options || !open) return null;
670 const rail = event.currentTarget;
671 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
672 const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']");
673 const sidebarContainer = sidebarRoot?.querySelector<HTMLElement>(
674 "[data-slot='sidebar-container']",
675 );
676 if (!wrapper || !sidebarRoot || !sidebarContainer) return null;
677​
678 const side = sidebarInstance?.side ?? "left";
679 let width = clampSidebarWidth(sidebarContainer.getBoundingClientRect().width, options);
680 // Write width only to gap/container during drag; inherited --sidebar-width restyles the whole app, so
681 // update it at commit.
682 const widthTargets = [
683 sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"),
684 sidebarContainer,
685 ].filter((element): element is HTMLElement => element !== null);
686 const setTargetWidth = (value: number) => {
687 widthTargets.forEach((element) => {
688 element.style.setProperty("width", `${value}px`);
689 });
690 };
691 widthTargets.forEach((element) => {
692 element.style.setProperty("transition-duration", "0ms");
693 });
694 setTargetWidth(width);
695​
696 return {
697 width,
698 edge: side === "left" ? "right" : "left",
699 bounds: { min: options.minWidth, max: options.maxWidth },
700 resize(nextWidth) {
701 const accepted =
702 options.shouldAcceptWidth?.({
703 currentWidth: width,
704 nextWidth,
705 rail,
706 side,
707 sidebarRoot,
708 wrapper,
709 }) ?? true;
710 if (accepted) {
711 setTargetWidth(nextWidth);
712 width = nextWidth;
713 }
714 return width;
715 },
716 finish(finalWidth, moved) {
717 wrapper.style.setProperty("--sidebar-width", `${finalWidth}px`);
718 suppressClickRef.current = moved;
719 if (options.storageKey) {
720 try {
721 setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite);
722 } catch (error) {
723 console.error("Could not persist sidebar width.", error);
724 }
725 }
726 options.onResize?.(finalWidth);
727 },
728 cleanup() {
729 widthTargets.forEach((element) => {
730 element.style.removeProperty("width");
731 element.style.removeProperty("transition-duration");
732 });
733 },
734 };
735 });
736 // Collapsing mid-drag abandons the drag instead of committing a width to a hidden sidebar.
737 // Created once: a new ref callback would run the old one's cleanup, which ends a live drag.
738 const [railRef] = React.useState(() => {
739 const { ref: dragRef, cancel } = resize;
740 return (node: HTMLButtonElement | null) => {
741 const releaseDrag = dragRef(node);
742 if (!releaseDrag) return undefined;
743 const stopCancelOnCollapse = onCollapse(cancel);
744 return () => {
745 stopCancelOnCollapse();
746 releaseDrag();
747 };
748 };
749 });
750​
751 const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
752 onClick?.(event);
753 if (event.defaultPrevented) return;
754 if (suppressClickRef.current) {
755 suppressClickRef.current = false;
756 event.preventDefault();
757 return;
758 }
759 if (resolvedResizable && open) {
760 event.preventDefault();
761 return;
762 }
763 toggleSidebar();
764 };
765​
766 // The rail spans the sidebar's full height, so the tip follows the pointer
767 // instead of floating at the rail's vertical middle.
768 return (
769 <Tooltip trackCursorAxis="y">
770 <TooltipTrigger
771 render={
772 <button
773 aria-label={railLabel}
774 className={cn(
775 /* A collapsed offcanvas rail overlaps the native scrollbar on Windows and Linux. */
776 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
777 "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",
778 "touch-none in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
779 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
780 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
781 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
782 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
783 className,
784 )}
785 data-sidebar="rail"
786 data-slot="sidebar-rail"
787 onClick={handleClick}
788 onLostPointerCapture={(event) => {
789 onLostPointerCapture?.(event);
790 if (!event.defaultPrevented) resize.onLostPointerCapture(event);
791 }}
792 onPointerCancel={(event) => {
793 onPointerCancel?.(event);
794 if (!event.defaultPrevented) resize.onPointerCancel(event);
795 }}
796 onPointerDown={(event) => {
797 onPointerDown?.(event);
798 if (!event.defaultPrevented) resize.onPointerDown(event);
799 }}
800 onPointerMove={(event) => {
801 onPointerMove?.(event);
802 if (!event.defaultPrevented) resize.onPointerMove(event);
803 }}
804 onPointerUp={(event) => {
805 onPointerUp?.(event);
806 if (!event.defaultPrevented) resize.onPointerUp(event);
807 }}
808 ref={railRef}
809 tabIndex={-1}
810 type="button"
811 {...props}
812 />
813 }
814 />
815 <TooltipPopup side="right">{railTitle}</TooltipPopup>
816 </Tooltip>
817 );
819​
820/**
821 * `pane` draws the single inset pane. `split` keeps the same insets but draws
822 * nothing, for a layout whose panes each draw their own pane with gaps.
823 */
824function SidebarInset({
825 className,
826 variant = "pane",
827 ...props
828}: React.ComponentProps<"main"> & { variant?: "pane" | "split" }) {
829 return (
830 <>
831 <div
832 aria-hidden
833 className="pointer-events-none fixed inset-y-0 hidden opacity-0 md:peer-data-[variant=inset]:block"
834 data-slot="sidebar-slide-surface"
835 />
836 <main
837 className={cn(
838 "relative flex min-w-0 w-full flex-1 flex-col",
839 variant === "pane" && "bg-background surface-grain",
840 // The inset pane (pen mSaSF) on the window chrome: 12px from the window
841 // and from the sidebar's content, centered on the chrome row at the top.
842 // It stretches in the flex wrapper, so the route's own height yields.
843 "md:peer-data-[variant=inset]:h-auto md:peer-data-[variant=inset]:overflow-hidden md:peer-data-[variant=inset]:mt-(--workspace-pane-inset-top) md:peer-data-[variant=inset]:me-(--workspace-pane-inset) md:peer-data-[variant=inset]:mb-(--workspace-pane-inset) md:peer-data-[variant=inset]:ms-(--workspace-sidebar-edge-inset) md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-(--workspace-pane-inset)",
844 variant === "pane" && "md:peer-data-[variant=inset]:rounded-pane",
845 className,
846 )}
847 data-slot="sidebar-inset"
848 {...props}
849 />
850 <div
851 aria-hidden
852 className="pointer-events-none fixed inset-y-0 hidden bg-(--app-chrome-background) opacity-0 surface-grain md:peer-data-[variant=inset]:block"
853 data-slot="sidebar-slide-frame"
854 />
855 </>
856 );
858​
859function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
860 return (
861 <div
862 className={cn("flex flex-col gap-2 p-2", className)}
863 data-sidebar="header"
864 data-slot="sidebar-header"
865 {...props}
866 />
867 );
869​
870/**
871 * Mirrors the window chrome row: the footer's controls end on the inset pane's
872 * bottom edge, and the shelf docked above centers as far from the bottom as
873 * the sidebar's first row does from the top.
874 */
875function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
876 return (
877 <div
878 className={cn(
879 "flex flex-col gap-2 px-[var(--sidebar-content-inset)] pt-2 pb-(--workspace-pane-inset)",
880 className,
881 )}
882 data-sidebar="footer"
883 data-slot="sidebar-footer"
884 {...props}
885 />
886 );
888​
889function SidebarContent({
890 className,
891 fixedHeader,
892 ...props
893}: React.ComponentProps<"div"> & {
894 fixedHeader?: React.ReactNode;
895}) {
896 return (
897 <>
898 {fixedHeader ? <div className="w-full shrink-0">{fixedHeader}</div> : null}
899 <ScrollArea
900 render={<TooltipScrollDismissArea suppressHoverWhileScrolling />}
901 hideScrollbars
902 scrollFade
903 scrollFadePadding={false}
904 // Rows provide keyboard access; exclude the viewport from tab order to flatten VoiceOver's WebKit
905 // interaction boundary.
906 viewportTabIndex={-1}
907 className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
908 >
909 <div
910 className={cn(
911 "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",
912 fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0",
913 className,
914 )}
915 data-sidebar="content"
916 data-slot="sidebar-content"
917 {...props}
918 />
919 </ScrollArea>
920 </>
921 );
923​
924function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
925 return (
926 <div
927 className={cn(
928 "relative flex w-full min-w-0 flex-col p-[var(--sidebar-content-inset)]",
929 className,
930 )}
931 data-sidebar="group"
932 data-slot="sidebar-group"
933 {...props}
934 />
935 );
937​
938function SidebarMenu({
939 className,
940 size = "default",
941 ...props
942}: React.ComponentProps<"ul"> & { size?: "default" | "compact" }) {
943 return (
944 <ul
945 className={cn(
946 "flex w-full min-w-0 flex-col",
947 size === "compact" ? "gap-0.5" : "gap-1",
948 className,
949 )}
950 data-sidebar="menu"
951 data-slot="sidebar-menu"
952 {...props}
953 />
954 );
956​
957function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
958 return (
959 <li
960 className={cn("group/menu-item relative", className)}
961 data-sidebar="menu-item"
962 data-slot="sidebar-menu-item"
963 {...props}
964 />
965 );
967​
968const sidebarMenuButtonVariants = cva(
969 "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:surface-sidebar-row-hover hover:text-sidebar-foreground focus-visible:[box-shadow:inset_0_0_0_1.5px_var(--ring)]! active:surface-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]:surface-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-(--sidebar-active-label) data-[state=open]:hover:surface-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-3.5 [&>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-active-icon)",
970 {
971 defaultVariants: {
972 size: "default",
973 variant: "default",
974 },
975 variants: {
976 size: {
977 // A leading 14px glyph centers on the sidebar's icon column (a 16px slot on
978 // the text line) and its label still starts after a 16px slot and 6px.
979 default:
980 "h-8 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-1.5 text-label has-[>svg:first-child]:ps-[calc(var(--sidebar-row-content-inset)+1px)] has-[>svg:first-child]:gap-[calc(var(--sidebar-control-gap)-1px)]",
981 // The default row on the 28px control pitch, for a row that stands in for
982 // the footer's icon controls: its glyph stays on their center line.
983 control:
984 "h-(--workspace-titlebar-control-size) rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] text-label has-[>svg:first-child]:ps-[calc(var(--sidebar-row-content-inset)+1px)] has-[>svg:first-child]:gap-[calc(var(--sidebar-control-gap)-1px)]",
985 project:
986 "h-8 gap-2.5 rounded-[var(--control-radius)] px-(--sidebar-row-content-inset) text-label",
987 compact: "h-7.5 gap-2 rounded-lg px-2 text-label",
988 icon: "size-8 justify-center rounded-[var(--control-radius)] p-0",
989 sm: "h-7 rounded-control p-2 text-meta",
990 },
991 variant: {
992 default: "text-sidebar-muted-foreground",
993 },
994 },
995 },
996);
997​
998function SidebarMenuButton({
999 isActive = false,
1000 variant = "default",
1001 size = "default",
1002 tooltip,
1003 className,
1004 render,
1005 ...props
1006}: useRender.ComponentProps<"button"> & {
1007 isActive?: boolean;
1008 tooltip?: string | React.ComponentProps<typeof TooltipPopup>;
1009} & VariantProps<typeof sidebarMenuButtonVariants>) {
1010 const { isMobile, state } = useSidebar();
1011​
1012 const defaultProps = {
1013 className: cn(sidebarMenuButtonVariants({ size, variant }), className),
1014 "data-active": isActive,
1015 "data-sidebar": "menu-button",
1016 "data-size": size,
1017 "data-slot": "sidebar-menu-button",
1018 };
1019​
1020 const buttonProps = mergeProps<"button">(defaultProps, props);
1021​
1022 const buttonElement = useRender({
1023 defaultTagName: "button",
1024 props: buttonProps,
1025 render,
1026 });
1027​
1028 if (!tooltip) {
1029 return buttonElement;
1031​
1032 if (typeof tooltip === "string") {
1033 tooltip = {
1034 children: tooltip,
1035 };
1037​
1038 return (
1039 <Tooltip>
1040 <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} />
1041 <TooltipPopup
1042 align="center"
1043 hidden={state !== "collapsed" || isMobile}
1044 side="right"
1045 {...tooltip}
1046 />
1047 </Tooltip>
1048 );
1050​
1051function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
1052 return (
1053 <ul
1054 className={cn(
1055 "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",
1056 "group-data-[collapsible=icon]:hidden",
1057 className,
1058 )}
1059 data-sidebar="menu-sub"
1060 data-slot="sidebar-menu-sub"
1061 {...props}
1062 />
1063 );
1065​
1066function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
1067 return (
1068 <li
1069 className={cn("group/menu-sub-item relative", className)}
1070 data-sidebar="menu-sub-item"
1071 data-slot="sidebar-menu-sub-item"
1072 {...props}
1073 />
1074 );
1076​
1077function SidebarMenuSubButton({
1078 size = "md",
1079 isActive = false,
1080 className,
1081 render,
1082 ...props
1083}: useRender.ComponentProps<"a"> & {
1084 size?: "sm" | "md";
1085 isActive?: boolean;
1086}) {
1087 const defaultProps = {
1088 className: cn(
1089 "-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:surface-sidebar-row-hover hover:text-sidebar-foreground focus-visible:[box-shadow:inset_0_0_0_1.5px_var(--ring)]! active:surface-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-3.5 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground",
1090 "data-[active=true]:surface-sidebar-row-selected data-[active=true]:text-strong-foreground",
1091 size === "sm" && "text-meta",
1092 size === "md" && "text-label",
1093 "group-data-[collapsible=icon]:hidden",
1094 className,
1095 ),
1096 "data-active": isActive,
1097 "data-sidebar": "menu-sub-button",
1098 "data-size": size,
1099 "data-slot": "sidebar-menu-sub-button",
1100 };
1101​
1102 return useRender({
1103 defaultTagName: "a",
1104 props: mergeProps<"a">(defaultProps, props),
1105 render,
1106 });
1108​
1109export {
1110 createSidebarOpenStore,
1111 Sidebar,
1112 SidebarContent,
1113 SidebarFooter,
1114 SidebarGroup,
1115 SidebarHeader,
1116 SidebarInset,
1117 SidebarMenu,
1118 SidebarMenuButton,
1119 SidebarMenuItem,
1120 SidebarMenuSub,
1121 SidebarMenuSubButton,
1122 SidebarMenuSubItem,
1123 SidebarProvider,
1124 SidebarRail,
1125 SidebarTrigger,
1126 useMobileSidebarSheetMounted,
1127 useSidebar,
1128 useSidebarVisibility,