"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}. Library body stays in * `library-secondary-nav.tsx` (domain-specific), not duplicated here. */ import * as React from "react" import { useLocation } from "react-router-dom" import { useRegisterNavFlyoutToggle, useSidebar } from "@/components/ui/sidebar" import { Tip } from "@/components/ui/tip" import { Button } from "@/components/ui/button" import { LibrarySecondaryNav } from "@/components/library-secondary-nav" import { NestedSecondaryPanelShell } from "@/components/templates/nested-secondary-panel-shell" import { Shortcut } from "@/components/ui/dropdown-menu" import { useIsMobile } from "@/hooks/use-mobile" import { useSidebarReflowZoom } from "@/hooks/use-sidebar-reflow-zoom" import { isLibrarySecondaryPanelRoute, isLibrarySecondaryPanelVisible, shouldLibrarySecondaryPanelBeOpen, } from "@/lib/library-nav" import { isSidebarHiddenPath } from "@/lib/focus-workflow" import type { LibraryItem } from "@/lib/mock/library" import type { LibraryFolder } from "@/lib/mock/library-folders" export type LibraryFolderBridge = { folders: LibraryFolder[] onFoldersChange: React.Dispatch> items: LibraryItem[] onItemsChange: React.Dispatch> } export type LibraryAccessBridge = { openManageAccess: () => void } // ───────────────────────────────────────────────────────────────────────────── // 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?: "restore" | "expand" | "collapse" | "leave" } 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 /** Library folder tree shared with the secondary nav while the hub is mounted. */ libraryFolderBridge: LibraryFolderBridge | null setLibraryFolderBridge: (bridge: LibraryFolderBridge | null) => void /** Opens the hub collaborators sheet from the secondary nav. */ libraryAccessBridge: LibraryAccessBridge | null setLibraryAccessBridge: (bridge: LibraryAccessBridge | null) => 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 } const SecondaryPanelContext = React.createContext({ activePanel: null, focusShellSupersedesPrimarySidebar: false, openPanel: () => {}, closePanel: () => {}, secondaryPanelCompact: false, collapseActiveSecondaryPanel: () => {}, libraryFolderBridge: null, setLibraryFolderBridge: () => {}, libraryAccessBridge: null, setLibraryAccessBridge: () => {}, secondaryFlyoutHidden: false, hideSecondaryFlyout: () => {}, showSecondaryFlyout: () => {}, secondaryFlyoutVisible: true, closeSecondaryFlyout: () => {}, }) export function useSecondaryPanel() { return React.useContext(SecondaryPanelContext) } export function SecondaryPanelProvider({ children }: { children: React.ReactNode }) { const [activePanel, setActivePanel] = React.useState(null) const [secondaryPanelCompact, setSecondaryPanelCompact] = React.useState(false) const [libraryFolderBridge, setLibraryFolderBridge] = React.useState(null) const [libraryAccessBridge, setLibraryAccessBridge] = React.useState(null) 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 librarySecondaryPanelRoute = isLibrarySecondaryPanelRoute(pathname) const { setOpen, restoreSavedOpen, open: mainSidebarOpen } = useSidebar() /** * Browser zoom ≥ 200% (or very short viewport) — same `useSidebarReflowZoom` * signal the primary sidebar uses (WCAG 1.4.10). 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]) const hideSecondaryFlyout = React.useCallback(() => { setSecondaryFlyoutHidden(true) setOpen(true, { persist: false }) }, [setOpen]) const showSecondaryFlyout = React.useCallback(() => { setSecondaryFlyoutHidden(false) setSecondaryPanelCompact(false) setOpen(false, { persist: false }) }, [setOpen]) /** 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 }) }, [setOpen], ) 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 the library scope rail (avoids empty shell / stuck panel races). */ React.useEffect(() => { if (isSidebarHiddenPath(pathname)) { if (activePanel) { closePanel({ mainSidebar: "leave" }) } return } if (shouldLibrarySecondaryPanelBeOpen(pathname)) { if (activePanel !== "library") { openPanel("library") } return } if (activePanel === "library") { closePanel({ mainSidebar: "leave" }) } }, [pathname, activePanel, openPanel, closePanel]) const handleSecondaryNavFlyoutToggle = React.useCallback((): boolean => { if (!navFlyout) return false if (librarySecondaryPanelRoute && activePanel !== "library") { openPanel("library") return true } if (activePanel !== "library") 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, librarySecondaryPanelRoute, 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, collapseActiveSecondaryPanel, libraryFolderBridge, setLibraryFolderBridge, libraryAccessBridge, setLibraryAccessBridge, secondaryFlyoutHidden, hideSecondaryFlyout, showSecondaryFlyout, secondaryFlyoutVisible, closeSecondaryFlyout, }), [ activePanel, focusShellSupersedesPrimarySidebar, openPanel, closePanel, secondaryPanelCompact, collapseActiveSecondaryPanel, libraryFolderBridge, libraryAccessBridge, secondaryFlyoutHidden, hideSecondaryFlyout, showSecondaryFlyout, secondaryFlyoutVisible, closeSecondaryFlyout, ], ) return ( {children} ) } // ───────────────────────────────────────────────────────────────────────────── // SecondaryPanel — the actual rendered panel // ───────────────────────────────────────────────────────────────────────────── function SecondaryPanelFlyoutHeader({ title, flyout, }: { title: string flyout: boolean }) { const { collapseActiveSecondaryPanel, closeSecondaryFlyout, hideSecondaryFlyout } = useSecondaryPanel() return (
{flyout ? (