{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "app-panes", "type": "registry:ui", "title": "AppPanes", "description": "The three-phase content row below a floating header: optional left sidebar, main region, optional right rail, each gated on the layout variants.", "categories": [ "navigation", "layout" ], "registryDependencies": [ "https://whiskeyjack.net/r/scroll-indicator.json", "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/app-panes.tsx", "type": "registry:ui", "target": "components/ui/app-panes.tsx", "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ScrollIndicator } from \"@/components/ui/scroll-indicator\";\n\n/**\n * The three-phase content row that sits below a floating `AppHeader`.\n *\n * Extracted from Chip Away and Icon Stack, which had grown near-identical\n * copies of it -- Icon Stack's own comments said \"mirrors Chip Away's\". The\n * phases are keyed to the `wide`/`xlwide` layout gates rather than plain width\n * breakpoints, so a portrait tablet keeps the tabbed layout and a landscape one\n * deploys the sidebar:\n *\n * - **below `wide`** -- a single centered column: just the main region.\n * - **`wide`..`xlwide`** -- the left sidebar appears and main shrinks to fill\n * the rest with no right gap.\n * - **`xlwide`+** -- the right rail appears and main centers its capped content\n * between the two.\n *\n * Both rails are **portal hosts**: the Layout owns the region, the page owns\n * what goes in it. A page reads the node from `useSidebarHost()` /\n * `useRailHost()` and `createPortal`s its content there, which keeps tab state\n * with the page that owns it.\n *\n * A rail whose `filled` is false collapses to zero width rather than\n * unmounting, so the portal host survives for a page that fills it a moment\n * later -- unmounting it produces a null host on the render that needed it.\n * `filled: 'auto'` lets a pane decide that for itself by watching its host for\n * children, for a Layout that would otherwise have to enumerate its pages.\n */\n\nconst SidebarHostContext = React.createContext(null);\nconst RailHostContext = React.createContext(null);\n\n/**\n * Resolves a pane's `filled`. A boolean passes through; `'auto'` watches the\n * host for children, which is how a Layout that does not know its pages' plans\n * still collapses an empty pane instead of holding the column open.\n */\nfunction usePaneFilled(host: HTMLElement | null, filled: boolean | \"auto\"): boolean {\n const [observed, setObserved] = React.useState(false);\n\n React.useEffect(() => {\n if (filled !== \"auto\" || !host) {\n setObserved(false);\n return;\n }\n const sync = () => setObserved(host.childElementCount > 0);\n sync();\n const observer = new MutationObserver(sync);\n observer.observe(host, { childList: true });\n return () => observer.disconnect();\n }, [host, filled]);\n\n return filled === \"auto\" ? observed : filled;\n}\n\n/** The left sidebar's DOM node, or null below `wide` / when unfilled. */\nexport const useSidebarHost = () => React.useContext(SidebarHostContext);\n/** The right rail's DOM node, or null below `xlwide` / when unfilled. */\nexport const useRailHost = () => React.useContext(RailHostContext);\n\nexport interface AppPaneConfig {\n /**\n * Whether the page has content for this pane. False collapses it to zero\n * width, keeping the host mounted.\n *\n * `'auto'` lets the pane answer for itself by watching its own host for\n * children. Prefer it when the Layout would otherwise have to enumerate which\n * pages fill which pane -- the Layout cannot read the host, since `AppPanes`\n * owns that node and only hands it down. Pass a boolean when the Layout\n * already knows (Icon Stack collapses both panes on a page with no source,\n * before any page has rendered into them).\n */\n filled: boolean | \"auto\";\n /**\n * Tailwind width classes. Defaults suit a nav rail (`w-64 2xl:w-72`) and a\n * content rail (`w-72 2xl:w-80`); override when a pane's content genuinely\n * needs different room -- Icon Stack's preview rail runs wider so previews\n * are not cramped.\n */\n width?: string;\n /** Accessible label for the region (caller-translated). */\n \"aria-label\"?: string;\n}\n\nexport interface AppPanesProps extends React.HTMLAttributes {\n /** Left sidebar, gated on `wide`. Omit for a layout with no sidebar. */\n sidebar?: AppPaneConfig;\n /** Right rail, gated on `xlwide`. Omit for a layout with no rail. */\n rail?: AppPaneConfig;\n /**\n * Distance from the top of the row to the first item, clearing the floating\n * header. Default `5.5rem` puts the first rail item on the same line as the\n * main region's first card for a 64px header.\n */\n railPaddingTop?: string;\n /** `topOffset` for each rail's bounded ScrollIndicator. Default 68. */\n indicatorTopOffset?: number;\n /** The main region -- normally an `AppMain`. */\n children: React.ReactNode;\n}\n\nexport const AppPanes = React.forwardRef(\n function AppPanes(\n {\n sidebar,\n rail,\n railPaddingTop = \"5.5rem\",\n indicatorTopOffset = 68,\n className,\n children,\n ...props\n },\n ref,\n ) {\n const [sidebarHost, setSidebarHost] = React.useState(null);\n const [railHost, setRailHost] = React.useState(null);\n const sidebarRef = React.useRef(null);\n const railRef = React.useRef(null);\n const sidebarFilled = usePaneFilled(sidebarHost, sidebar?.filled ?? false);\n const railFilled = usePaneFilled(railHost, rail?.filled ?? false);\n\n return (\n \n \n \n {sidebar && (\n \n {\n sidebarRef.current = el;\n setSidebarHost(el);\n }}\n aria-label={sidebar[\"aria-label\"]}\n className=\"h-full overflow-y-auto px-6 pb-8 scrollbar-hide\"\n style={{ paddingTop: railPaddingTop }}\n />\n {sidebarFilled && (\n \n )}\n \n )}\n\n {children}\n\n {rail && (\n \n {\n railRef.current = el;\n setRailHost(el);\n }}\n aria-label={rail[\"aria-label\"]}\n className=\"h-full overflow-y-auto px-6 pb-8 scrollbar-hide\"\n style={{ paddingTop: railPaddingTop }}\n />\n {railFilled && (\n \n )}\n \n )}\n \n \n \n );\n },\n);\n\nAppPanes.displayName = \"AppPanes\";\n" } ], "docs": "Sits inside AppShell, below AppHeader, wrapping AppMain. Both rails are portal hosts: the layout owns the region, the page owns its content via useSidebarHost() / useRailHost() and createPortal, which keeps tab state with the page. Gate on `wide`/`xlwide` rather than lg:/xl: so a portrait tablet keeps the tabbed layout and a landscape one deploys the sidebar. A rail with filled=false collapses to zero width rather than unmounting, so the host survives for a page that fills it a moment later. Use filled='auto' when the layout would otherwise have to enumerate which pages fill which pane: the pane watches its own host for children and collapses itself, which the layout cannot do because AppPanes owns that node. Layout stays in CSS wherever it can -- render both a TabBar and SidebarTabs and let the variants hide one; useLayoutGate is for the rare case one expensive component has to move between the two regions.", "meta": { "group": "navigation", "related": [ "app-shell", "scroll-indicator", "sidebar-tabs", "use-layout-gate" ], "exports": [ "AppPanes", "useSidebarHost", "useRailHost" ], "siteSlug": "app-panes" } }