"use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" import { useIsMobile } from "../../hooks/use-mobile" import { useSidebarReflowZoom } from "../../hooks/use-sidebar-reflow-zoom" import { NAV_FLYOUT_CHROME, NAV_FLYOUT_INNER, NAV_FLYOUT_INSET_LEFT, NAV_FLYOUT_INSET_RIGHT, NAV_FLYOUT_INSET_Y, } from "../../lib/nav-flyout-inset" import { cn } from "../../lib/utils" import { Button } from "./button" import { Input } from "./input" import { Separator } from "./separator" import { Skeleton } from "./skeleton" import { Tip } from "./tip" import { Tooltip, TooltipContent, TooltipTrigger, } from "./tooltip" /** * Cookie persisting the user's explicit expanded/collapsed preference. * * Versioned (`_v2`) on 2026-05-21 to drop stale values written by the * pre-fix code where incidental layout collapses (secondary panel open, * route auto-collapse) would clobber the user's preference on every * navigation. Anyone on the old `sidebar_state` cookie falls back to * the `defaultOpen` default (expanded), and the next explicit toggle * starts persisting under the new name. The old cookie self-expires * by `max-age` within a week; if you want immediate cleanup, the * client clears it explicitly on first mount (see `useLayoutEffect` * below). */ const SIDEBAR_COOKIE_NAME = "sidebar_state_v2" const SIDEBAR_COOKIE_LEGACY_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 /** Matches `useIsMobile` / Tailwind `md:` — do not apply desktop cookie to mobile overlay. */ const SIDEBAR_COOKIE_VIEWPORT_MQ = "(max-width: 767px)" const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" /** * Options for {@link SidebarContextProps.setOpen}. * * `persist: false` updates the visual sidebar state **without** writing the * `sidebar_state` cookie. Pass it from incidental layout effects — e.g. a * secondary panel taking the rail space, a route-level auto-collapse — so * the user's explicit preference (their ⌘B toggle / sidebar button) is the * only thing that ever rewrites the cookie. Defaults to `true` for * backward compatibility with the toggle button and existing callsites. */ type SetOpenOptions = { persist?: boolean } /** Return `true` when the handler consumed ⌘B / sidebar trigger (skip primary toggle). */ export type NavFlyoutToggleHandler = () => boolean type SidebarContextProps = { state: "expanded" | "collapsed" open: boolean setOpen: ( open: boolean | ((value: boolean) => boolean), opts?: SetOpenOptions, ) => void openMobile: boolean setOpenMobile: (open: boolean) => void isMobile: boolean /** Mobile or browser zoom ≥ ~200% — sidebar renders as a dismissible flyout. */ isNavFlyout: boolean toggleSidebar: () => void /** * Register a flyout toggle override (secondary panel, drill-in, …). * Handlers run in registration order; first `true` wins. */ registerNavFlyoutToggle: (handler: NavFlyoutToggleHandler) => () => void /** Close the flyout after the user picks a nav destination (mobile / high zoom). */ dismissNavFlyout: () => void /** * Snap the rail back to the user's saved preference — the value persisted in * `sidebar_state_v2`, or `defaultOpen` if no cookie was ever written. Use * this from incidental-collapse cleanups (secondary panel closing, route * leaves) so the rail doesn't stay stuck in its incidental state once the * thing that caused the collapse is gone. Always uses `persist: false`. */ restoreSavedOpen: () => void } /** * HMR-stable context identity. Vite Fast Refresh can re-evaluate this module * (e.g. after editing `button.tsx`, which sidebar imports) while an existing * `SidebarProvider` instance still writes to the previous `createContext` * object. Without a singleton, `useSidebar` in remounted children (Library, * etc.) reads a different context and throws "must be used within a * SidebarProvider". `Symbol.for` keeps one context across hot updates. */ const SIDEBAR_CONTEXT_GLOBAL = Symbol.for("exxat-ds.sidebar-context") type SidebarContextBag = typeof globalThis & { [SIDEBAR_CONTEXT_GLOBAL]?: React.Context } const SidebarContext = (globalThis as SidebarContextBag)[SIDEBAR_CONTEXT_GLOBAL] ?? ((globalThis as SidebarContextBag)[SIDEBAR_CONTEXT_GLOBAL] = React.createContext(null)) function useSidebar() { const context = React.useContext(SidebarContext) if (!context) { throw new Error("useSidebar must be used within a SidebarProvider.") } return context } function readSidebarStateCookie(): boolean | undefined { if (typeof document === "undefined") return undefined const m = document.cookie.match(new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=(true|false)(?:;|$)`)) if (!m) return undefined return m[1] === "true" } function useNavFlyout() { const isMobile = useIsMobile() const reflowZoom = useSidebarReflowZoom() return isMobile || reflowZoom } function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }: React.ComponentProps<"div"> & { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void }) { const isMobile = useIsMobile() const isNavFlyout = useNavFlyout() const navFlyoutToggleHandlersRef = React.useRef | null>(null) if (navFlyoutToggleHandlersRef.current === null) { navFlyoutToggleHandlersRef.current = new Set() } const [openMobile, setOpenMobile] = React.useState(false) // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) const open = openProp ?? _open const openRef = React.useRef(open) React.useEffect(() => { openRef.current = open }) // `setOpen` already persists `sidebar_state_v2` to a cookie on desktop; restore it on mount so // full reloads and new tabs keep the rail expanded/collapsed. Skip when controlled or on mobile. // Also actively drop the legacy `sidebar_state` cookie (pre-2026-05-21) so stale // collapsed-by-default values written by the old incidental-collapse bug don't linger. // // MOUNT-ONLY: deps are `[]` so this runs once per `SidebarProvider` mount, NOT // every time `open` flips. That matters because incidental collapses // (`setOpen(false, { persist: false })` from a secondary panel opening or // `SidebarAutoCollapse` route) must NOT trigger a re-reconcile that reads the // saved cookie back as "expanded" and snaps the rail open again. // The functional setter avoids closing over a stale `open` value. // Deps are `[openProp]` only — not `open` — so incidental collapses do not re-read the cookie. React.useLayoutEffect(() => { if (typeof window === "undefined") return if (typeof document !== "undefined" && document.cookie.includes(`${SIDEBAR_COOKIE_LEGACY_NAME}=`)) { document.cookie = `${SIDEBAR_COOKIE_LEGACY_NAME}=; path=/; max-age=0` } if (openProp !== undefined) return if (window.matchMedia(SIDEBAR_COOKIE_VIEWPORT_MQ).matches) return const fromCookie = readSidebarStateCookie() if (fromCookie === undefined) return _setOpen((prev) => (prev === fromCookie ? prev : fromCookie)) }, [openProp]) const setOpen = React.useCallback( ( value: boolean | ((value: boolean) => boolean), opts?: SetOpenOptions, ) => { const openState = typeof value === "function" ? value(open) : value if (setOpenProp) { setOpenProp(openState) } else { _setOpen(openState) } // Persist on desktop only — zooming in and closing shouldn't clobber the // desktop state. Callers can also opt out explicitly (`persist: false`) // for incidental layout collapses such as a secondary panel opening or // an auto-collapse route; those visual side-effects must not overwrite // the user's saved expanded/collapsed preference. if (!isNavFlyout && opts?.persist !== false) { document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` } }, [setOpenProp, open, isNavFlyout] ) // Mobile / high-zoom flyout: never leave the overlay open just because the // desktop cookie says expanded — that blocks the page on first paint after // zoom ≥ 200% or a narrow viewport. Restore the pre-flyout state on exit. const wasNavFlyoutRef = React.useRef(isNavFlyout) const savedOpenBeforeFlyoutRef = React.useRef(null) React.useLayoutEffect(() => { const wasNavFlyout = wasNavFlyoutRef.current if (isNavFlyout === wasNavFlyout) return const entering = isNavFlyout && !wasNavFlyout const leaving = !isNavFlyout && wasNavFlyout if (entering) { savedOpenBeforeFlyoutRef.current = openRef.current // Always start closed in flyout — user opens via trigger; click-outside / Esc closes. setOpen(false, { persist: false }) } if (leaving && savedOpenBeforeFlyoutRef.current !== null) { setOpen(savedOpenBeforeFlyoutRef.current, { persist: false }) savedOpenBeforeFlyoutRef.current = null } wasNavFlyoutRef.current = isNavFlyout }, [isNavFlyout, setOpen]) const registerNavFlyoutToggle = React.useCallback( (handler: NavFlyoutToggleHandler) => { const handlers = navFlyoutToggleHandlersRef.current if (!handlers) return () => {} handlers.add(handler) return () => { handlers.delete(handler) } }, [], ) // Helper to toggle the sidebar (primary flyout unless a nested handler wins). const toggleSidebar = React.useCallback(() => { if (isNavFlyout) { const handlers = navFlyoutToggleHandlersRef.current if (handlers) { for (const handler of handlers) { if (handler()) return } } } setOpen((open) => !open) }, [isNavFlyout, setOpen]) /** * Snap the rail back to the user's saved preference. Called when a secondary * panel closes or an auto-collapse route is left — so an incidental collapse * doesn't persist beyond the page that caused it. `persist: false` because * this restore is itself incidental; the only writes to the cookie should * still come from real user gestures (⌘B, sidebar button). */ const restoreSavedOpen = React.useCallback(() => { if (typeof window === "undefined") return const fromCookie = readSidebarStateCookie() const target = fromCookie ?? defaultOpen setOpen(target, { persist: false }) }, [defaultOpen, setOpen]) // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { event.preventDefault() toggleSidebar() return } // WCAG 2.1.1 — Escape closes the floating sidebar on mobile/high-zoom if (event.key === "Escape" && isNavFlyout && open) { event.preventDefault() setOpen(false) } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [toggleSidebar, isNavFlyout, open, setOpen]) // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed" const dismissNavFlyout = React.useCallback(() => { if (isNavFlyout && open) { setOpen(false, { persist: false }) } }, [isNavFlyout, open, setOpen]) const contextValue = React.useMemo( () => ({ state, open, setOpen, isMobile, isNavFlyout, openMobile, setOpenMobile, toggleSidebar, registerNavFlyoutToggle, restoreSavedOpen, dismissNavFlyout, }), [state, open, setOpen, isMobile, isNavFlyout, openMobile, setOpenMobile, toggleSidebar, registerNavFlyoutToggle, restoreSavedOpen, dismissNavFlyout] ) return (
{children}
) } function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, dir: _dir, ...props }: React.ComponentProps<"div"> & { side?: "left" | "right" variant?: "sidebar" | "floating" | "inset" collapsible?: "offcanvas" | "icon" | "none" }) { const { state, setOpen } = useSidebar() const isNavFlyout = useNavFlyout() if (collapsible === "none") { return (
{children}
) } return (