"use client" /** * SecondaryPanel — nested rail between the primary icon sidebar and content. * Full width shows hub scope nav; **compact** matches the primary sidebar icon rail (`w-12`). * * Chrome uses {@link NestedSecondaryPanelShell}. *Which* rails exist, and what * goes inside them, is app-owned: each is a `SecondaryPanelSpec` registered * through `AppShellSlots.secondaryPanels`. Everything here is the machinery * around them — width, the compact icon strip, the reflow flyout, and the * open/close state machine that keeps the rail in step with the URL. */ import * as React from "react" import { useLocation } from "react-router" import { useRegisterNavFlyoutToggle, useSidebar } from "../ui/sidebar" import { Tip } from "../ui/tip" import { Button } from "../ui/button" import { NestedSecondaryPanelShell, SECONDARY_PANEL_WIDTH_DEFAULT, SECONDARY_PANEL_WIDTH_KEY, SECONDARY_PANEL_WIDTH_MAX, SECONDARY_PANEL_WIDTH_MIN, clampSecondaryPanelWidth, } from "../templates/nested-secondary-panel-shell" import { ResizableHandle, ResizablePanel, } from "../ui/resizable" import { useShellRailWidth } from "../../hooks/use-shell-rail-width" import { NAV_FLYOUT_SCROLL_BODY } from "../../lib/nav-flyout-inset" import { Shortcut } from "../ui/dropdown-menu" import { useIsMobile } from "../../hooks/use-mobile" import { useSidebarReflowZoom } from "../../hooks/use-sidebar-reflow-zoom" import { useAppShellSlots, type SecondaryPanelCloseTarget, type SecondaryPanelSpec, } from "./app-shell-slots" import { cn } from "../../lib/utils" // ───────────────────────────────────────────────────────────────────────────── // Registry // ───────────────────────────────────────────────────────────────────────────── const NO_PANELS: SecondaryPanelSpec[] = [] /** A spec's mount routes, defaulting to the routes its flyout toggle owns. */ function panelMountsOn(spec: SecondaryPanelSpec, pathname: string): boolean { return (spec.autoOpen ?? spec.flyoutRoute)(pathname) } // ───────────────────────────────────────────────────────────────────────────── // Context // ───────────────────────────────────────────────────────────────────────────── export type ClosePanelOptions = { /** * Main app sidebar after the secondary panel closes. * - `restore` (default): snap back to the user's saved preference * (`sidebar_state_v2` cookie, or `defaultOpen` if unset). Use this when * leaving a page whose secondary panel caused an incidental collapse — the * rail returns to the state the user explicitly set elsewhere via ⌘B / the * sidebar button. * - `leave`: only clear the active panel — keep the rail in whatever state it * was in (rare; prefer `restore`). * - `expand`: force the full primary rail open. Use only when the product * explicitly wants the wide rail after dismiss. * - `collapse`: keep the icon rail. Use when the next route also wants compact. */ mainSidebar?: SecondaryPanelCloseTarget } export type OpenPanelOptions = { /** Icon-only nested rail — used on focus shells (`/library/new`) so scope nav stays compact. */ compact?: boolean } interface SecondaryPanelContextValue { /** Currently active panel id, or null if none */ activePanel: string | null /** * Focus shell (`NewFocusTemplate`) with nested library scope nav — primary * sidebar is hidden; secondary stays on the compact icon rail. */ focusShellSupersedesPrimarySidebar: boolean /** Open a panel by id. Pass `{ compact: true }` to keep the icon rail. */ openPanel: (id: string, opts?: OpenPanelOptions) => void /** Close the panel (programmatic / route cleanup). */ closePanel: (opts?: ClosePanelOptions) => void /** Icon-only nested rail while the panel stays “open”. Cleared by {@link openPanel} / {@link closePanel}. */ secondaryPanelCompact: boolean /** Narrow icon rail (primary-sidebar-style); keeps {@link activePanel} mounted. */ collapseActiveSecondaryPanel: () => void /** * Flyout stack (mobile / ≥200% zoom): temporarily hide the secondary overlay * so the primary sidebar underneath is reachable. Cleared by {@link openPanel}. */ secondaryFlyoutHidden: boolean hideSecondaryFlyout: () => void showSecondaryFlyout: () => void /** Flyout sheet visible (mobile / high zoom). False when user closed the sheet but stayed on the hub. */ secondaryFlyoutVisible: boolean /** Hide the scope sheet only — keeps {@link activePanel} for `/library/all`. */ closeSecondaryFlyout: () => void /** * Declare an app overlay that must not stay open behind a scope rail, and get * an unregister back. * * The shell already yields the layer to itself — opening a panel collapses the * primary sidebar and closes docked Leo — but Leo was named in this file, so * every other overlay an app owns had no way in. Prefer * {@link useExclusiveShellOverlay}, which registers and unregisters with the * overlay's own open state. */ registerExclusiveOverlay: (close: () => void) => () => void } const SecondaryPanelContext = React.createContext({ activePanel: null, focusShellSupersedesPrimarySidebar: false, openPanel: () => {}, closePanel: () => {}, secondaryPanelCompact: false, collapseActiveSecondaryPanel: () => {}, secondaryFlyoutHidden: false, hideSecondaryFlyout: () => {}, showSecondaryFlyout: () => {}, secondaryFlyoutVisible: true, closeSecondaryFlyout: () => {}, registerExclusiveOverlay: () => () => {}, }) export function useSecondaryPanel() { return React.useContext(SecondaryPanelContext) } /** * Close an app-owned overlay whenever a scope rail takes the layer. * * `open` is the overlay's own state, `close` its own closer: the shell decides * when the layer changes hands, and the overlay keeps deciding what closing * means. Registration follows `open`, so an unmounted or already-closed overlay * is never called. * * Use it for anything that occupies the rail's space or competes for the same * attention — a docked assistant, a drill-in, a persistent side panel. Not for * modal dialogs: those already trap focus, and closing one behind the user's back * loses whatever they were confirming. */ export function useExclusiveShellOverlay(open: boolean, close: () => void) { const { registerExclusiveOverlay } = useSecondaryPanel() // Kept in a ref so a fresh closer each render does not re-register, which // would drop the registration for a frame while a panel was opening. const latest = React.useRef(close) React.useEffect(() => { latest.current = close }, [close]) React.useEffect(() => { if (!open) return return registerExclusiveOverlay(() => latest.current()) }, [open, registerExclusiveOverlay]) } // Stable fallbacks for unfilled shell wiring. Module constants rather than // inline literals so the effect dependency arrays below stay honest — a fresh // arrow function each render would re-run route effects on every render. const NEVER_HIDES_SIDEBAR = () => false const IGNORE_ASK_LEO_OPEN = () => {} const LEAVE_MAIN_SIDEBAR = () => "leave" as const export function SecondaryPanelProvider({ children }: { children: React.ReactNode }) { const [activePanel, setActivePanel] = React.useState(null) const [secondaryPanelCompact, setSecondaryPanelCompact] = React.useState(false) const [secondaryFlyoutHidden, setSecondaryFlyoutHidden] = React.useState(false) const [secondaryFlyoutVisible, setSecondaryFlyoutVisible] = React.useState(true) const { pathname } = useLocation() const isMobile = useIsMobile() const reflowZoom = useSidebarReflowZoom() const navFlyout = isMobile || reflowZoom const { setOpen, restoreSavedOpen, open: mainSidebarOpen } = useSidebar() const { slots, on, routes } = useAppShellSlots() const panels = slots.secondaryPanels ?? NO_PANELS const setAskLeoOpen = on.setAskLeoOpen ?? IGNORE_ASK_LEO_OPEN const askLeoDockedOpen = on.askLeoDockedOpen ?? false // No policy supplied means no route hides the sidebar, which is the right // default for an app that has no focus workflows. const isSidebarHiddenPath = routes.hidesSidebar ?? NEVER_HIDES_SIDEBAR const mainSidebarOnPanelClose = routes.mainSidebarOnPanelClose ?? LEAVE_MAIN_SIDEBAR // Docked Leo collapses the rail to icons without clearing the user's expand // preference — when Leo closes, labels return if they had them. const effectiveSecondaryCompact = !navFlyout && (secondaryPanelCompact || askLeoDockedOpen) /** * WCAG 1.4.10 reflow (≤320px width, ≥200% zoom, or short viewport) — same * `useSidebarReflowZoom` signal the primary sidebar uses. At that scale the * nested rail must **not** stay pinned as an icon strip beside content. Hide * it until the user opens Back / Main menu (one flyout layer at a time). */ const wasReflowZoomRef = React.useRef(false) React.useEffect(() => { if (reflowZoom && !wasReflowZoomRef.current && activePanel) { setOpen(false, { persist: false }) } wasReflowZoomRef.current = reflowZoom }, [reflowZoom, activePanel, setOpen]) /** Icon-only compact rail is desktop-only — flyout sheets always show labels. */ React.useEffect(() => { if (navFlyout && secondaryPanelCompact) { setSecondaryPanelCompact(false) } }, [navFlyout, secondaryPanelCompact]) /* App overlays that yield when a scope rail takes the layer. A ref, not state: registering must not re-render the shell, and the set is only ever read at the moment a panel opens. */ const exclusiveOverlays = React.useRef(new Set<() => void>()) const registerExclusiveOverlay = React.useCallback((close: () => void) => { exclusiveOverlays.current.add(close) return () => { exclusiveOverlays.current.delete(close) } }, []) const closeExclusiveOverlays = React.useCallback(() => { // Copied first: a closer that unregisters itself as it closes would // otherwise mutate the set being iterated. for (const close of [...exclusiveOverlays.current]) close() }, []) const hideSecondaryFlyout = React.useCallback(() => { setSecondaryFlyoutHidden(true) setOpen(true, { persist: false }) }, [setOpen]) const showSecondaryFlyout = React.useCallback(() => { setSecondaryFlyoutHidden(false) setSecondaryPanelCompact(false) setSecondaryFlyoutVisible(true) setOpen(false, { persist: false }) setAskLeoOpen(false) closeExclusiveOverlays() }, [setOpen, setAskLeoOpen, closeExclusiveOverlays]) /** Scope sheet visible → keep primary flyout closed (not stacked). */ React.useEffect(() => { if (!navFlyout || !activePanel || secondaryFlyoutHidden || !secondaryFlyoutVisible) { return } setOpen(false, { persist: false }) }, [navFlyout, activePanel, secondaryFlyoutHidden, secondaryFlyoutVisible, setOpen]) const closeSecondaryFlyout = React.useCallback(() => { setSecondaryPanelCompact(false) setSecondaryFlyoutHidden(false) setSecondaryFlyoutVisible(false) setOpen(false, { persist: false }) }, [setOpen]) const openPanel = React.useCallback( (id: string, opts?: OpenPanelOptions) => { setSecondaryPanelCompact(opts?.compact ?? false) setSecondaryFlyoutHidden(false) setSecondaryFlyoutVisible(true) setActivePanel(id) setOpen(false, { persist: false }) setAskLeoOpen(false) closeExclusiveOverlays() }, [setOpen, setAskLeoOpen, closeExclusiveOverlays], ) const closePanel = React.useCallback((opts?: ClosePanelOptions) => { setSecondaryPanelCompact(false) setSecondaryFlyoutHidden(false) setSecondaryFlyoutVisible(true) setActivePanel(null) const mainSidebar = opts?.mainSidebar ?? "restore" if (mainSidebar === "leave") return if (mainSidebar === "restore") { restoreSavedOpen() return } if (mainSidebar === "collapse") { setOpen(false, { persist: false }) return } setOpen(true, { persist: false }) }, [restoreSavedOpen, setOpen]) /** URL is source of truth for nested scope rails (avoids empty shell / stuck panel races). */ React.useEffect(() => { if (isSidebarHiddenPath(pathname)) { if (activePanel) { closePanel({ mainSidebar: "leave" }) } return } const mounted = panels.find(spec => panelMountsOn(spec, pathname)) if (mounted) { if (activePanel !== mounted.id) { openPanel(mounted.id) } return } // Only registry panels close on navigation. One opened imperatively by // `useAutoPanel` is owned by the route that opened it, and closing it here // would fight that route's own cleanup. if (activePanel && panels.some(spec => spec.id === activePanel)) { // The destination decides the rail: a record detail wants the icon strip // for canvas, so leaving primary expanded after a hub visit is wrong. closePanel({ mainSidebar: mainSidebarOnPanelClose(pathname) }) } }, [ pathname, activePanel, openPanel, closePanel, isSidebarHiddenPath, mainSidebarOnPanelClose, panels, ]) const handleSecondaryNavFlyoutToggle = React.useCallback((): boolean => { if (!navFlyout) return false const owner = panels.find(spec => spec.flyoutRoute(pathname)) if (owner && activePanel !== owner.id) { openPanel(owner.id) return true } if (!activePanel || !panels.some(spec => spec.id === activePanel)) return false if (secondaryFlyoutHidden) { if (mainSidebarOpen) { setOpen(false, { persist: false }) } else { showSecondaryFlyout() } return true } if (secondaryFlyoutVisible) { closeSecondaryFlyout() } else { setSecondaryPanelCompact(false) setSecondaryFlyoutVisible(true) setOpen(false, { persist: false }) } return true }, [ navFlyout, panels, pathname, activePanel, secondaryFlyoutHidden, secondaryFlyoutVisible, mainSidebarOpen, showSecondaryFlyout, closeSecondaryFlyout, openPanel, setOpen, ]) useRegisterNavFlyoutToggle(handleSecondaryNavFlyoutToggle) const collapseActiveSecondaryPanel = React.useCallback(() => { setSecondaryPanelCompact(true) }, []) /** * Desktop: when the user expands the primary sidebar (⌘B) while a scope rail * is open, collapse the secondary panel to its icon strip so both rails are * not fighting for width. Expanding labels via « still collapses primary * (`openPanel`). */ React.useEffect(() => { if (navFlyout || !activePanel || !mainSidebarOpen || secondaryPanelCompact) { return } setSecondaryPanelCompact(true) }, [navFlyout, activePanel, mainSidebarOpen, secondaryPanelCompact]) const focusShellSupersedesPrimarySidebar = isSidebarHiddenPath(pathname) const value = React.useMemo( () => ({ activePanel, focusShellSupersedesPrimarySidebar, openPanel, closePanel, secondaryPanelCompact: effectiveSecondaryCompact, collapseActiveSecondaryPanel, secondaryFlyoutHidden, hideSecondaryFlyout, showSecondaryFlyout, secondaryFlyoutVisible, closeSecondaryFlyout, registerExclusiveOverlay, }), [ activePanel, focusShellSupersedesPrimarySidebar, openPanel, closePanel, effectiveSecondaryCompact, collapseActiveSecondaryPanel, secondaryFlyoutHidden, hideSecondaryFlyout, showSecondaryFlyout, secondaryFlyoutVisible, closeSecondaryFlyout, registerExclusiveOverlay, ], ) return ( {children} ) } // ───────────────────────────────────────────────────────────────────────────── // SecondaryPanel — the actual rendered panel // ───────────────────────────────────────────────────────────────────────────── /** * The panel's own title: a heading, in sans. * * Not `font-heading`. Ivy Presto is the page `

`'s and nothing else's — the base * stylesheet says so in as many words — and a 20px serif here put a second display * title in the viewport competing with the real one a few hundred pixels to its * right, which is the one naming the record the reader came for. * * Not the uppercase micro-label either, which is what it briefly became. That is the * voice of `SidebarGroupLabel` and of `SecondaryHubNavSectionHeader`, and the second * of those renders the section headings *inside* this panel — "Folders", "Group". * A panel titled in the same case and size as the sections it contains flattens the * two into one level, and the reader has nothing to tell the panel's name from a * heading over three of its rows. * * `SectionHeading`'s treatment instead (`components/product-home/section-heading.tsx`), * which is the sentence-case sans heading this design system already uses to head a * block. It leaves three legible steps in the column: 16 for the panel, 14 for the * rows, 12 uppercase for the section eyebrows. */ const PANEL_TITLE_CLASS = "m-0 min-w-0 truncate py-0.5 text-base font-semibold tracking-tight text-sidebar-foreground" function SecondaryPanelFlyoutHeader({ title, flyout, }: { title: string flyout: boolean }) { const { collapseActiveSecondaryPanel, closeSecondaryFlyout, hideSecondaryFlyout } = useSecondaryPanel() return (
{flyout ? (