// ~/src/siteplane/components/sidebar/sidebar.tsx "use client"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { PanelLeftIcon } from "lucide-react"; import * as React from "react"; import { useMediaQuery } from "../../system/hooks/use-media-query"; import { focusRingRecipe } from "../../system/recipes/focus-ring.recipe"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { Button, type ButtonBaseProps } from "../button"; import { KitButton } from "../button/button-internal"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { Input } from "../input"; import { KitInput } from "../input/input-internal"; import { ScrollArea } from "../scroll-area"; import { Separator } from "../separator"; import { Sheet, SheetHeader, SheetTitle } from "../sheet"; import { KitSheetPopup } from "../sheet/sheet-internal"; import type { KitSidebarProviderProps } from "./sidebar-internal"; import { Skeleton } from "../skeleton"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger, } from "../tooltip"; const SIDEBAR_COOKIE_NAME: string = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE: number = 60 * 60 * 24 * 7; // The desktop widths are the stylesheet's (design-rework.css, "Sidebar"). const SIDEBAR_WIDTH_MOBILE: string = "18rem"; const SIDEBAR_KEYBOARD_SHORTCUT: string = "b"; export const sidebarMenuPopupWidthClassName = "w-56"; type SidebarMenuButtonSize = SiteplaneControlSize; type SidebarMenuButtonVariant = "default" | "outline" | "workspace"; export type SidebarLayout = "inset" | "push"; export type SidebarContextProps = { layout?: SidebarLayout | undefined; state: "expanded" | "collapsed"; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; mobileBreakpoint: "md" | "lg"; toggleSidebar: () => void; }; export const SidebarContext: React.Context = React.createContext(null); export function useSidebar(): SidebarContextProps { const context = React.useContext(SidebarContext); if (!context) { throw new Error("useSidebar must be used within a SidebarProvider."); } return context; } // A layout reserves the sidebar's leading 8px gutter on the container itself // (the default variant only; floating and inset already pad the container). // Header, groups and footer then add only their trailing inset, so a layout // sidebar insets rows by the same 8px on both sides and every part keeps its // block padding. const SidebarGutterContext: React.Context = React.createContext(false); // The id that SidebarTrigger and SidebarRail name in aria-controls: the // provider's own, or the id the consumer gives Sidebar. type SidebarControls = { id: string; setId: (id: string | undefined) => void; }; const SidebarControlsContext: React.Context = React.createContext(null); type SidebarCollapsible = "offcanvas" | "icon" | "none"; // Sidebar tells its parts how it collapses, so a menu item knows when it sits // on the collapsed icon rail. const SidebarCollapsibleContext: React.Context = React.createContext(null); // A menu item with an expand trigger tells its row to step aside while the // trigger stands in for it on the collapsed icon rail. const SidebarMenuItemContext: React.Context = React.createContext(false); // Ctrl/Cmd+B stays with a field that edits text: there it is Bold, or a caret // move in macOS text fields. const nonTextInputTypes: ReadonlySet = new Set([ "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit", ]); function isEditableTarget(target: EventTarget | null): boolean { if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) { return false; } if (target.isContentEditable) return true; if ( target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement ) { return true; } return ( target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type) ); } function isCurrent(ariaCurrent: React.AriaAttributes["aria-current"]): boolean { return ( ariaCurrent !== undefined && ariaCurrent !== false && ariaCurrent !== "false" ); } function getSidebarLengthInPixels( element: HTMLElement, name: string, fallback: number, ): number { const value = getComputedStyle(element).getPropertyValue(name).trim(); const parsedValue = Number.parseFloat(value); if (!Number.isFinite(parsedValue)) { return fallback; } if (value.endsWith("rem")) { const rootFontSize = Number.parseFloat( getComputedStyle(document.documentElement).fontSize, ); return parsedValue * (Number.isFinite(rootFontSize) ? rootFontSize : 16); } return parsedValue; } // Persistence is best effort: the Cookie Store API is missing in some browsers // and in insecure contexts, and a blocked write must never reject. function persistSidebarOpenState(open: boolean, name: string): void { const value = String(open); const store = (globalThis as { cookieStore?: CookieStore }).cookieStore; try { if (store && typeof store.set === "function") { store .set({ expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, name, path: "/", value, }) .catch(() => undefined); return; } if (typeof document !== "undefined") { document.cookie = `${name}=${value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; } } catch { // Cookies can be unavailable, for example in a sandboxed document. } } type SidebarProviderProps = React.ComponentProps<"div"> & { layout?: SidebarLayout; defaultOpen?: boolean; mobileBreakpoint?: "md" | "lg"; keyboardShortcut?: boolean; /** * The cookie that keeps the desktop state: its name, or `false` for no * cookie. The provider writes it on every real change and never reads it; * pass its value to `defaultOpen` on the server. Default `sidebar_state`. */ stateCookie?: string | false; open?: boolean; onOpenChange?: (open: boolean) => void; }; export function SidebarProvider( providerProps: SidebarProviderProps, ): React.ReactElement { // Header Navigation passes the internal forceMobile through // KitSidebarProvider (sidebar-internal.ts), the same function. const { layout, defaultOpen = true, mobileBreakpoint = "md", keyboardShortcut = true, stateCookie = SIDEBAR_COOKIE_NAME, open: openProp, onOpenChange: setOpenProp, className, style, children, forceMobile = false, ...props } = providerProps as KitSidebarProviderProps; const viewportMobile = useMediaQuery(`max-${mobileBreakpoint}`); const isMobile = forceMobile || viewportMobile; const [openMobile, setOpenMobile] = React.useState(false); const generatedId = React.useId(); const [sidebarId, setSidebarId] = React.useState(); const controls = React.useMemo( () => ({ id: sidebarId ?? generatedId, setId: setSidebarId }), [generatedId, sidebarId], ); // 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; // An empty name is no cookie either. const cookieName = stateCookie || null; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value; if (openProp === undefined) { _setOpen(openState); // The cookie keeps the sidebar state and records real changes only. if (openState !== open && cookieName) persistSidebarOpenState(openState, cookieName); } setOpenProp?.(openState); }, [setOpenProp, openProp, open, cookieName], ); // A controlled state reaches the cookie once its parent has applied it, so // a change the parent rejects is never written. const persistedOpenProp = React.useRef(openProp); React.useEffect(() => { if (openProp === persistedOpenProp.current) return; persistedOpenProp.current = openProp; if (openProp !== undefined && cookieName) persistSidebarOpenState(openProp, cookieName); }, [openProp, cookieName]); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { if (!keyboardShortcut) return; const handleKeyDown = (event: KeyboardEvent): void => { if ( event.key !== SIDEBAR_KEYBOARD_SHORTCUT || !(event.metaKey || event.ctrlKey) || // Another handler took the keys, an input method is composing, or a // text field or editor has focus: the keys are theirs. event.defaultPrevented || event.isComposing || isEditableTarget(event.target) ) { return; } event.preventDefault(); toggleSidebar(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [toggleSidebar, keyboardShortcut]); // 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 contextValue = React.useMemo( () => ({ layout, isMobile, mobileBreakpoint, open, openMobile, setOpen, setOpenMobile, state, toggleSidebar, }), [ layout, state, open, setOpen, isMobile, mobileBreakpoint, openMobile, toggleSidebar, ], ); return (
{children}
); } export function Sidebar({ side = "left", variant = "sidebar", mobileVariant = "default", collapsible = "offcanvas", id: idProp, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, className, style, children, ...props }: React.ComponentProps<"div"> & { side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; mobileVariant?: "default" | "fullscreen"; collapsible?: SidebarCollapsible; }): React.ReactElement { const { layout, isMobile, mobileBreakpoint, state, openMobile, setOpenMobile, } = useSidebar(); const controls = React.useContext(SidebarControlsContext); const setControlsId = controls?.setId; // A consumer's id names the sidebar for the triggers' aria-controls. React.useEffect(() => { if (idProp === undefined || !setControlsId) return; setControlsId(idProp); return () => setControlsId(undefined); }, [idProp, setControlsId]); const id = idProp ?? controls?.id; // The landmark (desktop) or the dialog (mobile) carries the name. Only the // attributes the consumer sets are passed on: an undefined aria-labelledby // would replace the one the sheet's title gives its dialog. const labelProps = { ...(ariaDescribedBy === undefined ? null : { "aria-describedby": ariaDescribedBy }), ...(ariaLabelledBy === undefined ? null : { "aria-labelledby": ariaLabelledBy }), id, }; // The sidebar sits flat on the page background in every layout: no frame, // border or shadow of its own. The main surface carries the edge. const layoutClassName = cn( layout && "ps-2", layout === "push" && "z-20 bg-[var(--sp-rw-bg-base)]", ); // The sidebar's own tooltips open at once; tooltips elsewhere in the app keep // their provider's delay. const content = ( {children} ); if (collapsible === "none") { return (
{content}
); } if (isMobile) { // Every prop reaches the sheet popup, the element the sidebar is on a // small screen; aria-label becomes the dialog's title. return ( {ariaLabel ?? "Sidebar"}
{content}
); } // The desktop sidebar is a complementary landmark, so its navigation, account // controls and header content stay within a landmark region. The landmark // takes the id and the name; className, style and the other props stay on // the sidebar container they always styled. return ( ); } // The state a trigger announces: the desktop expansion, or on a small screen // whether the sheet is open. aria-controls names the sidebar while it exists. function useSidebarTriggerState(): { "aria-controls": string | undefined; "aria-expanded": boolean; } { const { isMobile, open, openMobile } = useSidebar(); const controls = React.useContext(SidebarControlsContext); const expanded = isMobile ? openMobile : open; return { "aria-controls": isMobile && !openMobile ? undefined : controls?.id, "aria-expanded": expanded, }; } export function SidebarTrigger({ children, className, onClick, ...props }: React.ComponentProps): React.ReactElement { const { toggleSidebar } = useSidebar(); const triggerState = useSidebarTriggerState(); return ( ) => { onClick?.(event); // The mobile sheet has no trigger of its own, so on close Base UI // returns focus to the element that had it before the sheet opened. // Safari focuses no button it presses; the trigger takes focus itself, // without the keyboard ring, so closing returns focus to it there too. const trigger = event.currentTarget; if (trigger.ownerDocument.activeElement !== trigger) trigger.focus({ focusVisible: false, preventScroll: true, } as FocusOptions); toggleSidebar(); }} profile="compact-icon-action" shape="square" variant="ghost" {...props} > {children ?? ( <> {/* The panel glyph shows the sidebar at the inline start, so it mirrors in a right-to-left page. */} Toggle sidebar )} ); } export function SidebarRail({ className, resizable = false, onClick, onPointerDown, ...props }: React.ComponentProps & { resizable?: boolean; }): React.ReactElement { const { state, toggleSidebar } = useSidebar(); const triggerState = useSidebarTriggerState(); const didDragRef = React.useRef(false); // Ends a resize drag: restores the page's cursor and text selection. const endDragRef = React.useRef<(() => void) | null>(null); React.useEffect(() => () => endDragRef.current?.(), []); function handlePointerDown( event: React.PointerEvent, ): void { onPointerDown?.(event); // A new press starts fresh: only a drag in this press may swallow its // click. didDragRef.current = false; if (event.button !== 0 || !resizable || state !== "expanded") { return; } const sidebar = event.currentTarget.closest( '[data-slot="sidebar"]', ); const sidebarContainer = sidebar?.querySelector( '[data-slot="sidebar-container"]', ); const sidebarWrapper = event.currentTarget.closest( '[data-slot="sidebar-wrapper"]', ); if (!sidebar || !sidebarContainer || !sidebarWrapper) { return; } const wrapper = sidebarWrapper; // The side is the inline start or end: in a right-to-left page a left // sidebar sits on the right, so its edge moves the other way. const rightToLeft = getComputedStyle(sidebar).direction === "rtl"; const onPhysicalRight = (sidebar.dataset.side ?? "left") === "right" ? !rightToLeft : rightToLeft; const startX = event.clientX; const startWidth = sidebarContainer.getBoundingClientRect().width; const minWidth = getSidebarLengthInPixels( wrapper, "--sidebar-width-min", 192, ); const maxWidth = getSidebarLengthInPixels( wrapper, "--sidebar-width-max", 384, ); // A drag that never saw its release ends before the next one starts. endDragRef.current?.(); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; didDragRef.current = false; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; function handlePointerMove(moveEvent: PointerEvent): void { const delta = onPhysicalRight ? startX - moveEvent.clientX : moveEvent.clientX - startX; if (Math.abs(delta) > 3) { didDragRef.current = true; } const nextWidth = Math.min( maxWidth, Math.max(minWidth, startWidth + delta), ); wrapper.style.setProperty( "--sidebar-width", `${Math.round(nextWidth)}px`, ); } // A release, a cancelled touch drag or an unmount ends the drag. Only a // release is followed by the click that the drag must not turn into a // toggle. function endDrag(endEvent?: PointerEvent): void { document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", endDrag); window.removeEventListener("pointercancel", endDrag); endDragRef.current = null; if (endEvent?.type !== "pointerup") didDragRef.current = false; } endDragRef.current = endDrag; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", endDrag); window.addEventListener("pointercancel", endDrag); } // Supplied handlers run first; the rail still toggles and resizes. return (