import { type ComponentPropsWithRef, createContext, type CSSProperties, type Dispatch, type PointerEvent, type ReactNode, type Ref, type SetStateAction, use, useEffect, useMemo, useRef, useState, } from "react"; import { cn } from "../../lib/utils"; import { Divider } from "../divider"; import { LayoutBackground } from "./layout-background"; import { type Appearance, AppearanceProvider } from "../appearance"; import { useClickOutside } from "../../hooks/use-click-outside"; import { useMedia } from "../../hooks/use-media"; import { media } from "../../lib/screens"; import { useSuppressMotion } from "../../hooks/use-suppress-motion"; type ToggleContextValue = { open: boolean; collapsible?: boolean; setOpen: Dispatch>; }; type LeftWidthContextValue = { /** Current left sidebar width in pixels (clamped to `[min, max]`). */ width: number; /** Narrowest allowed width in pixels. */ min: number; /** Widest allowed width in pixels. */ max: number; /** Commit a new width; the value is clamped to `[min, max]`. */ setWidth: (value: number) => void; /** Reset the width to its default. */ reset: () => void; /** Signal the start of an interactive drag (disables the grid transition). */ begin: () => void; /** Signal the end of an interactive drag. */ end: () => void; }; type LayoutContextValue = { nav: ToggleContextValue; left: ToggleContextValue; right: ToggleContextValue; }; type LayoutState = { nav: "expanded" | "collapsed"; left: "expanded" | "collapsed"; right: "expanded" | "collapsed"; }; const NavContext = createContext(null); const LeftContext = createContext(null); const RightContext = createContext(null); const LeftWidthContext = createContext(null); /** Default width of the left sidebar in pixels (matches the CSS `--panel-left-width`). */ const DEFAULT_LEFT_WIDTH = 200; /** Narrowest the left sidebar can be dragged, in pixels. */ const MIN_LEFT_WIDTH = 200; /** Widest the left sidebar can be dragged, in pixels. */ const MAX_LEFT_WIDTH = 480; const SLOT = [ "[data-slot='dialog-content']", "[data-slot='dialog-overlay']", "[data-slot='dropdown-menu-content']", "[data-slot='context-menu-content']", "[data-slot='popover-content']", "[data-slot='sheet-content']", "[data-slot='sheet-overlay']", "[data-slot='tooltip-content']", ].join(","); function setRef(ref: Ref | undefined, value: T) { if (typeof ref === "function") ref(value); else if (ref) (ref as { current: T }).current = value; } /** Access the nav panel's open state and toggle. */ function useLayoutNav() { let ctx = use(NavContext); if (!ctx) throw new Error("useLayoutNav must be used within Layout"); return ctx; } /** Same as `useLayoutNav` but returns null instead of throwing when no Layout is present. */ function useLayoutNavOptional() { return use(NavContext); } /** Access the left sidebar's open state and toggle. */ function useLayoutLeft() { let ctx = use(LeftContext); if (!ctx) throw new Error("useLayoutLeft must be used within Layout"); return ctx; } /** Access the right sidebar's open state and toggle. */ function useLayoutRight() { let ctx = use(RightContext); if (!ctx) throw new Error("useLayoutRight must be used within Layout"); return ctx; } /** Access the left sidebar's resizable width. Returns null when no Layout is present. */ function useLayoutLeftWidth() { return use(LeftWidthContext); } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } /** Creates a toggle that's either controlled (external value + callback) or uncontrolled (internal useState). */ function useToggle( controlled: boolean | undefined, onChange: Dispatch> | undefined, fallback: boolean, ): ToggleContextValue { let [internal, setInternal] = useState(fallback); if (controlled !== undefined) { return { open: controlled, setOpen: onChange || setInternal }; } return { open: internal, setOpen: setInternal }; } type LayoutOptions = { defaultNavOpen: boolean; leftOpen?: boolean; onLeftChange?: Dispatch>; defaultLeftOpen: boolean; rightOpen?: boolean; onRightChange?: Dispatch>; defaultRightOpen: boolean; }; function useLayoutValue(opts: LayoutOptions): LayoutContextValue { let sm = useMedia(media.sm); let md = useMedia(media.md); let lg = useMedia(media.lg); let [navOpen, setNavOpen] = useState(opts.defaultNavOpen); let nav = useMemo( () => ({ open: navOpen, setOpen: setNavOpen, collapsible: md }), [navOpen, md], ); let leftDesktop = useToggle(opts.leftOpen, opts.onLeftChange, opts.defaultLeftOpen); let leftCompact = useToggle(undefined, undefined, false); let left = sm ? leftDesktop : leftCompact; let rightDesktop = useToggle(opts.rightOpen, opts.onRightChange, opts.defaultRightOpen); let rightCompact = useToggle(undefined, undefined, false); let right = lg ? rightDesktop : rightCompact; return { nav, left, right }; } function getLayoutState(layout: LayoutContextValue): LayoutState { return { nav: layout.nav.open ? "expanded" : "collapsed", left: layout.left.open ? "expanded" : "collapsed", right: layout.right.open ? "expanded" : "collapsed", }; } type Props = ComponentPropsWithRef<"div"> & { /** Visual appearance mode. Defaults to `"web"`. */ appearance?: Appearance; /** Use static CSS dither instead of WebGPU canvas. */ fallback?: boolean; /** Show the rounded-rect glow around the main panel to signal a loading state. */ loading?: boolean; /** Multiplier for glow opacity. */ glowOpacity?: number; /** Glow fade-in duration in seconds (0 = canvas default). */ glowFadeIn?: number; /** Glow fade-out duration in seconds (0 = canvas default). */ glowFadeOut?: number; /** Nav component. */ nav?: ReactNode; /** Default nav open state. */ defaultNavOpen?: boolean; /** Default left sidebar open state (uncontrolled). */ defaultLeftOpen?: boolean; /** Controlled left sidebar open state. */ leftOpen?: boolean; /** Callback when left sidebar open state changes. */ onLeftChange?: Dispatch>; /** Controlled left sidebar width in pixels. */ leftWidth?: number; /** Default left sidebar width in pixels (uncontrolled). */ defaultLeftWidth?: number; /** Callback when the left sidebar width changes (drag committed). */ onLeftWidthChange?: (value: number) => void; /** Narrowest the left sidebar can be dragged, in pixels. */ minLeftWidth?: number; /** Widest the left sidebar can be dragged, in pixels. */ maxLeftWidth?: number; /** Default right sidebar open state (uncontrolled). */ defaultRightOpen?: boolean; /** Controlled right sidebar open state. */ rightOpen?: boolean; /** Callback when right sidebar open state changes. */ onRightChange?: Dispatch>; }; /** * Application layout with CSS grid, dither canvas, and nav/sidebar state. * Children with `layout:left`, `layout:main`, `layout:right` classes snap into the grid. */ function Layout({ className, children, ref, appearance = "web", fallback, loading, glowOpacity, glowFadeIn, glowFadeOut, style, nav, leftOpen, onLeftChange, leftWidth, onLeftWidthChange, defaultLeftWidth = DEFAULT_LEFT_WIDTH, minLeftWidth = MIN_LEFT_WIDTH, maxLeftWidth = MAX_LEFT_WIDTH, rightOpen, onRightChange, defaultNavOpen = true, defaultLeftOpen = true, defaultRightOpen = false, ...props }: Props) { let shell = useRef(null); let layout = useLayoutValue({ defaultNavOpen, leftOpen, onLeftChange, defaultLeftOpen, rightOpen, onRightChange, defaultRightOpen, }); let state = getLayoutState(layout); let sm = useMedia(media.sm); let lg = useMedia(media.lg); let leftCompact = !sm && layout.left.open; let rightCompact = !lg && layout.right.open; let [internalWidth, setInternalWidth] = useState(defaultLeftWidth); let [resizing, setResizing] = useState(false); let rawWidth = leftWidth ?? internalWidth; let width = clamp(rawWidth, minLeftWidth, maxLeftWidth); let leftWidthValue = useMemo(() => { function commit(value: number) { let next = clamp(Math.round(value), minLeftWidth, maxLeftWidth); if (leftWidth === undefined) setInternalWidth(next); onLeftWidthChange?.(next); } return { width, min: minLeftWidth, max: maxLeftWidth, setWidth: commit, reset: () => commit(defaultLeftWidth), begin: () => setResizing(true), end: () => setResizing(false), }; }, [width, minLeftWidth, maxLeftWidth, defaultLeftWidth, leftWidth, onLeftWidthChange]); useSuppressMotion(shell); useEffect(() => { if (!leftCompact && !rightCompact) return; let el = shell.current; if (!el) return; let items = Array.from(el.children).filter((item): item is HTMLElement => item instanceof HTMLElement ); let prev = items.map(item => [item, item.inert] as const); let active = new Set( items.filter((item) => { if (leftCompact && item.classList.contains("layout:left")) return true; if (rightCompact && item.classList.contains("layout:right")) return true; return false; }), ); if (!active.size) return; items.forEach(item => { item.inert = !active.has(item); }); return () => { prev.forEach(([item, inert]) => { item.inert = inert; }); }; }, [leftCompact, rightCompact]); return (
{appearance === "native" && ( // Keep the top gutter outside panel no-drag regions, including the full-height sidebar.
); } type SidebarProps = ComponentPropsWithRef<"div"> & { /** Which side of the layout this sidebar occupies. */ side: "left" | "right"; /** Class name for the inner wrapper that owns sidebar children. */ innerClassName?: string; }; /** Sidebar panel that snaps into the layout grid based on `side` prop. */ function Sidebar({ children, side, className, innerClassName, ref, ...props }: SidebarProps) { let node = useRef(null); let left = useLayoutLeft(); let right = useLayoutRight(); let sm = useMedia(media.sm); let lg = useMedia(media.lg); let item = side === "left" ? left : right; let compact = side === "left" ? !sm && left.open : !lg && right.open; useClickOutside(node, compact, () => item.setOpen(false), SLOT); return (
{ node.current = el; setRef(ref, el); }} className={cn( side === "left" ? "layout:left" : "layout:right", "electrobun-webkit-app-region-no-drag", className, )} {...props} > {side === "left" && } {side === "left" && }
{children}
); } function LeftDivider() { let left = useLayoutLeft(); return (