"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 { MicroLabel } from "@/components/typography"; 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; /** * Which element scrolls the page. * - `"document"` (default) — the shell's height only FLOORS at the viewport * and grows with its content, so the document itself is the scroller. That * is what restores the iOS rubber-band, and with it pull-to-refresh, in an * installed PWA. * - `"fixed"` — the pre-existing viewport-bound shell: the document cannot * scroll and every overflow is owned by an inner pane. Required by pages * whose content is itself viewport-bound — a map or canvas sized `h-full`, * a kanban board on `100svh`, or any `fillHeight` tab — since those resolve * to zero (or overflow the viewport) without a definite height above them. * * The mode is published as `data-scroll-mode` on `` so an app's global * stylesheet can scope root-level rules (`overflow: hidden`, * `overscroll-behavior-y: none`) to fixed pages only. Applying those * unscoped is what suppressed the document scroll and the bounce everywhere. */ scroll?: "document" | "fixed"; }; // Rail trigger class: the rail is a LIST, not a column of buttons — the same // treatment as the handbook reader's section rail (HandbookPageNavigator / // HandbookPageToc in a360ai): entries at `text-xs` on a `border-s-2` rule, and // the active one marked by turning that rule and its label `primary` rather // than by growing a filled box around it. // // Every override below is written in the SAME class group AND the same variant // modifiers as the base `TabsTrigger` class it has to beat, because that is the // only way tailwind-merge inside cn() drops the base one; a differently-scoped // utility (`data-active:` against a `dark:data-active:` base) would survive as // a second rule and win or lose on stylesheet order. // // The previous version targeted `data-[state=active]`, which these Base UI tabs // never set — the active row was therefore still wearing the base // `data-active:bg-background` box, which is the button look this replaces. const railTriggerClass = cn( "flex h-auto w-full items-center justify-start px-3 py-1 text-start text-xs leading-tight whitespace-normal", // `border-0` first: the base sets a 1px box on all four sides, and only a // later border-width utility of the same group removes it. `border-s-2` then // draws the rail itself. "rounded-none border-0 border-s-2 border-transparent bg-transparent", "text-muted-foreground dark:text-muted-foreground transition-colors", "hover:bg-transparent hover:text-primary dark:hover:text-primary", "data-active:bg-transparent dark:data-active:bg-transparent", "data-active:border-primary dark:data-active:border-primary", "data-active:text-primary dark:data-active:text-primary", "data-active:font-medium data-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, scroll = "fixed", }: 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); }, []); // Publish the mode for the app stylesheet. Deliberately NO cleanup: on a // client navigation the next page's container mounts before this one // unmounts, so resetting the attribute here would stomp the incoming page's // mode. Only one container renders per page, so the last write always // describes the page on screen. useEffect(() => { document.documentElement.dataset.scrollMode = scroll; }, [scroll]); const isFixed = scroll === "fixed"; // `scroll="fixed"` reproduces the previous classes exactly — that mode is the // old behaviour under a name. In document mode every clip and inner scroller // between the shell and the content is dropped so the document is the only // scroller; leaving a single `overflow-hidden` in that chain silently // truncates the page instead of letting it grow. const shellHeight = isFixed ? `h-[calc(100svh-var(--app-header-h,3rem))]` : `min-h-[calc(100svh-var(--app-header-h,3rem))]`; const clip = isFixed ? `overflow-hidden` : ``; const scrollY = isFixed ? `overflow-y-auto` : ``; // The bar is an in-flow sibling of the content (see MobileNavigationBar's own // note). In document mode the column is taller than the viewport, so in-flow // means it only appears once the user reaches the very bottom — `sticky` // pins it to the viewport's bottom edge while keeping it in flow, so it still // settles under the card at the end of the page rather than floating over it. const mobileBarClass = isFixed ? `` : `sticky bottom-0 z-30`; // 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 activeTabDefinition = tabs?.find((t) => tabValue(t) === activeTab); const activeFillHeight = activeTabDefinition?.fillHeight === true; const activeConstrainWidth = activeTabDefinition?.constrainWidth === 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={cn(`flex min-w-0 grow data-[orientation=horizontal]:flex-row`, isFixed && `h-full`, clip)} > {/* 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 are full-bleed — a canvas, a map or a two-pane browser wants the width — unless the tab asks for the reading column with `constrainWidth`. */}
{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}
))}
); }