"use client"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components"; import { partitionTabs, Tab } from "@/components/containers"; import { HEADER_ROW_MIN_H, RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle"; import { Header, MobileNavigationBar } from "@/components/navigations"; import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts"; import { useUrlRewriter } from "@/hooks"; import { cn, useIsMobile } from "@/index"; import { ModuleWithPermissions } from "@/permissions"; import { useSearchParams } from "next/navigation"; import { Fragment, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; const DETAILS_COOKIE_NAME = "round_page_details_state"; const DETAILS_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; type RoundPageContainerProps = { module?: ModuleWithPermissions; id?: string; details?: ReactNode; tabs?: Tab[]; children?: ReactNode; fullWidth?: boolean; forceHeader?: boolean; header?: ReactNode; /** * Section-navigation layout for `tabs`. * - `"tabs"` (default) — horizontal `TabsList` (desktop) / `Select` (mobile). * Unchanged from prior behaviour; existing callers need no edits. * - `"rail"` — vertical 220px left rail grouped by each tab's `group`, with a * ` void; /** * `data-testid` for the page shell. Applied to the outermost content wrapper * of BOTH return branches — the pre-hydration one and the main one — so an * e2e suite gating page-readiness on the testid can attach before hydration * completes rather than racing it. */ testId?: string; /** * Initial state of the `details` panel before the persisted preference (the * `round_page_details_state` cookie) is read. Defaults to `false` — the panel * starts collapsed, which suits an informational aside. * * Pass `true` when `details` holds PRIMARY navigation rather than an aside * (e.g. the chat / conversation list), where a collapsed-by-default panel * would hide the page's main affordance behind a toggle. The stored * preference still wins in both directions once the user sets one. * * Requires the title bar to be rendered (`!fullWidth || forceHeader`) — * that is where the toggle lives, so a `fullWidth` caller passing `details` * without `forceHeader` leaves the panel unreachable. */ defaultDetailsOpen?: boolean; /** * Heading for the `details` panel. Rendered as a fixed header above the * panel's scroll area, used as the mobile `Sheet` title (replacing the old * hardcoded "Details"), and woven into the toggle's tooltip — so the control * reads "Show conversations" rather than an unlabelled icon. * * Strongly recommended whenever `details` holds primary navigation: without * it the panel is an unlabelled column and the toggle is unguessable. */ detailsTitle?: ReactNode; /** * Icon for the `details` toggle. Defaults to an info glyph, which suits an * informational aside. Pass a panel/list glyph when `details` holds primary * navigation — an "info" icon actively misdescribes a conversation list. */ detailsIcon?: ReactNode; }; // Rail trigger class: override the horizontal TabsTrigger defaults for a // vertical, left-aligned, dark-filled active state. tailwind-merge inside cn() // resolves the conflicts with the base classes. const railTriggerClass = cn( "flex w-full items-center justify-start rounded-md px-3 py-1.5 text-left text-sm leading-tight whitespace-normal", "text-muted-foreground", "hover:bg-muted hover:text-foreground", "data-[state=active]:bg-foreground data-[state=active]:text-background", "data-[state=active]:font-semibold data-[state=active]:shadow-none", ); /** Stable value for the URL `?section=` and active-tab matching. */ const tabValue = (tab: Tab): string => tab.sectionKey ?? tab.key?.name ?? tab.label; export function RoundPageContainer({ module, id, details, tabs, children, fullWidth, forceHeader, header, layout = "tabs", onSectionChange, testId, defaultDetailsOpen = false, detailsTitle, detailsIcon, }: RoundPageContainerProps) { const headerChildren = useHeaderChildren(); const headerLeftContent = useHeaderLeftContent(); const headerLogo = useHeaderLogo(); const headerMobileChildren = useHeaderMobileChildren(); const [showDetails, setShowDetailsState] = useState(defaultDetailsOpen); const isMobile = useIsMobile(); const [mounted, setMounted] = useState(false); useEffect(() => { const match = document.cookie.split("; ").find((row) => row.startsWith(`${detailsCookieName}=`)); const stored = match?.split("=")[1]; // Apply the stored preference in BOTH directions. Previously this only ever // opened the panel, which was harmless while the initial state was always // `false` — but with `defaultDetailsOpen` a caller can start open, and a user // who explicitly collapsed the panel must stay collapsed on the next load. if (stored === "true") setShowDetailsState(true); else if (stored === "false") setShowDetailsState(false); }, []); useEffect(() => { setMounted(true); }, []); // Scope the persisted preference PER MODULE. A single global cookie meant // collapsing an informational aside on one page silently collapsed a primary // navigation panel on another — so a page declaring `defaultDetailsOpen` // could still load collapsed because of an unrelated page's cookie. const detailsCookieName = module?.name ? `${DETAILS_COOKIE_NAME}_${module.name}` : DETAILS_COOKIE_NAME; const setShowDetails = useCallback( (value: boolean) => { setShowDetailsState(value); document.cookie = `${detailsCookieName}=${value}; path=/; max-age=${DETAILS_COOKIE_MAX_AGE}`; }, [detailsCookieName], ); const searchParams = useSearchParams(); const section = searchParams.get("section"); const rewriteUrl = useUrlRewriter(); const initialValue = tabs ? (section && tabs.find((i) => tabValue(i) === section) ? section : null) || tabValue(tabs[0]) : undefined; const [activeTab, setActiveTab] = useState(initialValue); useEffect(() => { if (tabs && section) { const tab = tabs.find((i) => tabValue(i) === section); if (tab) { setActiveTab(section); } } }, [section, tabs]); const handleTabChange = useCallback( (key: string) => { setActiveTab(key); if (module && id) { rewriteUrl({ page: module, id: id, additionalParameters: { section: key } }); } else { // No backing entity (e.g. the settings hub): still reflect the active // section in the URL by rewriting ?section= against the current path. rewriteUrl({ page: window.location.pathname, additionalParameters: { section: key } }); } onSectionChange?.(key); }, [module, id, rewriteUrl, onSectionChange], ); const activeFillHeight = tabs?.find((t) => tabValue(t) === activeTab)?.fillHeight === true; // Rail partition — only consumed by `layout="rail"` but cheap to compute. const { ungrouped, groups } = useMemo(() => partitionTabs(tabs ?? []), [tabs]); const tabItems = useMemo( () => Object.fromEntries((tabs ?? []).map((tab) => [tabValue(tab), tab.contentLabel ?? tab.label])), [tabs], ); const isReady = mounted; if (!isReady) { return ( <>
{headerChildren}
{/* `main`, not `div`: this is the page's content landmark. PageContainer used to supply it, and the migration to RoundPageContainer dropped it, leaving every authenticated page with no "skip to main content" target. `main` is block-level like `div`, so the flex classes below behave identically and nothing shifts. data-testid stays here so the e2e pre-hydration attach is unaffected. */}
{/* `min-h-0 flex-1`, NOT `h-full`: the bar below is an in-flow sibling in this fixed-height flex column. `h-full` would resolve to 100% of the wrapper, leaving no room and pushing the bar below the fold (visible only after scrolling to the end of the page). */}
); } return ( <>
{headerChildren}
{/* svh, NOT dvh: iOS leaves dvh stale-short after the software keyboard closes (standalone PWAs especially), which opens a dead band under the bottom bar. svh is constant — the fully visible area in standalone, the chrome-visible area in Safari — so the bar can never end up above OR below the fold. */}
{/* `min-h-0 flex-1`, NOT `h-full`: MobileNavigationBar below is an in-flow sibling in this fixed-height flex column. `h-full` resolves to 100% of the wrapper, leaving the bar no room and pushing it below the fold — it then appears only after scrolling to the very end of the page. */}
{(!fullWidth || forceHeader) && ( )}
{layout === "rail" && tabs ? ( // Rail layout: the vertical-tab navigation is a flush-left // sidebar of the card and the content fills the full remaining // width (like `fullWidth`) — NOT the centred max-w-6xl column. / below, so orientation is free to be // horizontal here. `data-[orientation=horizontal]:flex-row` overrides // the shadcn root's default `data-[orientation=horizontal]:flex-col` // to keep the rail and content side by side. orientation="horizontal" className="flex h-full min-w-0 grow overflow-hidden data-[orientation=horizontal]:flex-row" > {/* Flush-left section rail — md and up */} {/* Content — full width, fills the remaining space */}
{/* Section Select — below md */}
{/* Centre and constrain rail content (like the non-rail layout). Fill-height tabs keep the full width. */}
{header} {tabs.map((tab) => ( {tab.content} ))} {children &&
{children}
}
) : (
{header} {tabs ? ( <> {isMobile ? (
) : (
{tabs.map((tab) => ( {tab.contentLabel ?? tab.label} ))}
)}
{tabs.map((tab) => ( {tab.content} ))}
{children &&
{children}
} ) : ( children )}
)}
{details && (isMobile ? ( {detailsTitle ?? "Details"}
{details}
) : (
{detailsTitle && ( // Mirrors RoundPageContainerTitle's structure exactly — border on an // OUTER wrapper, height floor on the INNER row. Putting the border // inside the measured row instead leaves this header 1px short of // the page title bar, since `box-sizing: border-box` absorbs it.
{detailsTitle}
)}
{details}
))}
); }