| 1 | import { mergeProps } from "@base-ui/react/merge-props"; |
| 2 | import { useRender } from "@base-ui/react/use-render"; |
| 3 | import { cva, type VariantProps } from "class-variance-authority"; |
| 4 | import { PanelLeft, PanelLeftClose } from "lucide"; |
| 5 | import * as React from "react"; |
| 6 | import { cn } from "~/lib/utils"; |
| 7 | import { Button } from "~/components/ui/button"; |
| 8 | import { Input, type InputProps } from "~/components/ui/input"; |
| 9 | import { MorphIcon } from "~/components/MorphIcon"; |
| 10 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 11 | import { |
| 12 | Sheet, |
| 13 | SheetDescription, |
| 14 | SheetHeader, |
| 15 | SheetPopup, |
| 16 | SheetTitle, |
| 17 | } from "~/components/ui/sheet"; |
| 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 19 | import { useResizeDrag } from "~/hooks/useResizeDrag"; |
| 20 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 21 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; |
| 22 | import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | |
| 25 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; |
| 26 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; |
| 27 | const SIDEBAR_WIDTH = "16rem"; |
| 28 | const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; |
| 29 | const SIDEBAR_WIDTH_ICON = "3rem"; |
| 30 | const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; |
| 31 | const SIDEBAR_TITLEBAR_CONTROL_SIZE = "max-sm:[--workspace-titlebar-control-size:--spacing(8)]"; |
| 32 | |
| 33 | type 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 | |
| 45 | type 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 | |
| 60 | type 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 | |
| 75 | type SidebarInstanceContextProps = { |
| 76 | resizable: SidebarResolvedResizableOptions | null; |
| 77 | side: "left" | "right"; |
| 78 | }; |
| 79 | |
| 80 | const SidebarContext = React.createContext<SidebarContextProps | null>(null); |
| 81 | const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null); |
| 82 | |
| 83 | function 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 | |
| 92 | function useSidebarVisibility() { |
| 93 | const { isMobile, open, openMobile } = useSidebar(); |
| 94 | return isMobile ? openMobile : open; |
| 95 | } |
| 96 | |
| 97 | function 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 | |
| 202 | function 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 | |
| 349 | function 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 | |
| 376 | function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number { |
| 377 | return Math.max(options.minWidth, Math.min(width, options.maxWidth)); |
| 378 | } |
| 379 | |
| 380 | function 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. */ |
| 618 | function 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 | |
| 631 | function 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 | |
| 645 | function 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 | |
| 656 | function 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 | |
| 667 | function 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 | |
| 707 | function 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 | |
| 721 | function 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 | |
| 732 | function 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 | |
| 743 | const 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 | |
| 766 | function 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 | |
| 819 | function 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 | |
| 834 | function 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 | |
| 845 | function 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 | |
| 877 | export { |
| 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 | }; |