"use client" /** * ListPageTemplate — reusable template for any list-based page. * * Scroll: table / list / board tabs grow with `[data-page-scroll]` (see * `.cursor/rules/exxat-list-page-hub-scroll.mdc`). Folder / panel / tree / dashboard * tabs fill the viewport and scroll internally. * * Provides: page header slot, optional key metrics, tabbed views * (table/list/board/dashboard) with add/remove/configure per-tab, * and an export drawer. * * Usage: * } * metrics={} * defaultTabs={DEFAULT_TABS} * renderContent={(tab) => } * /> * * Connected views (table | list | board | dashboard) must share one `useTableState` * and pass `tableState.rows` into non-table surfaces — see `docs/data-views-pattern.md` * and `AGENTS.md` §4. * * View chrome is shared with `ViewSegmentedControl` / `viewSegmentedToolbarClass` in * `@/components/ui/view-segmented-control` and re-exported from `@/components/data-views`. */ import * as React from "react" import { cn } from "../../lib/utils" import { horizontalPadding, measureRowLabels, naturalRowWidth, useRowFitLadder, type RowFitMeasurement, } from "../../hooks/use-row-fit-ladder" import { useRememberedPageScroll } from "../../hooks/use-remembered-page-scroll" import { Tip } from "../ui/tip" import { Button } from "../ui/button" import { HorizontalScrollRegion } from "../ui/horizontal-scroll-region" import { Input } from "../ui/input" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuRadioGroup, DropdownMenuRadioItem, Shortcut, } from "../ui/dropdown-menu" import type { DataListViewType } from "../../lib/data-list-view" import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "../../lib/data-list-view" import { createListPageEditViewHandler, type OpenTablePropertiesHandle, } from "../../lib/list-page-table-properties" import { TABLE_ONLY_HUB_SUPPORTED_VIEWS } from "../../lib/data-list-view-registry" import { viewSegmentedToolbarClass, viewSegmentedButtonClass, } from "../ui/view-segmented-control" import { usePersistedState } from "../../lib/persisted-state" import type { RowHeight } from "../../lib/row-height" const ExportDrawer = React.lazy(() => import("../ui/export-drawer").then((m) => ({ default: m.ExportDrawer })), ) // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type ViewType = DataListViewType /** * Opt-in viewport-fill tab bodies. Empty by default so every hub view * (including folder / panel / tree) grows with `[data-page-scroll]` like table. */ const LIST_PAGE_VIEWPORT_FILL_VIEWS = new Set([]) /** Same labels/icons as Properties drawer `SelectionTileGrid` — single source in `DATA_LIST_VIEW_TILES`. */ export const VIEW_TYPES: { type: ViewType; label: string; icon: string }[] = DATA_LIST_VIEW_TILES.map(t => ({ type: t.value, label: t.label, icon: t.icon, })) export interface FilterOption { id: string label: string } export interface ViewTab { id: string label: string viewType: ViewType icon: string /** Optional filter key for lifecycle or category-based filtering */ filterId: string /** * Per-view row density preference captured at creation time (or later via * Properties). Consumers that render tables/boards can read this to set the * initial `rowHeight` for that specific view tab. */ rowHeight?: RowHeight } export interface ListPageTemplateProps { /** Page header — rendered above metrics */ header: React.ReactNode /** Optional metrics strip — rendered below header */ metrics?: React.ReactNode /** * Default visibility for the metrics strip when not controlled. * Persisted under `:listpage:show-metrics:v1` when `persistKey` is set. * @default true */ defaultShowMetrics?: boolean /** Whether to show metrics (controlled externally) */ showMetrics?: boolean /** Called when metrics visibility changes (controlled mode) */ onShowMetricsChange?: (show: boolean) => void /** Initial tabs (uncontrolled mode) */ defaultTabs: ViewTab[] /** * Controlled tabs — when all four are provided, tab state is owned by the parent * (e.g. for localStorage). Otherwise `defaultTabs` + internal state are used. */ tabs?: ViewTab[] onTabsChange?: (tabs: ViewTab[]) => void activeTabId?: string onActiveTabChange?: (id: string) => void /** Filter options per tab (e.g. All, Upcoming, Ongoing, Completed) */ filterOptions?: FilterOption[] /** Label for the filter sub-menu (default: "Filter") */ filterLabel?: string /** Get count for a tab's filter (for badge) */ getTabCount?: (filterId: string) => number /** Render the content for the active tab */ renderContent: (tab: ViewTab, updateTab: (patch: Partial) => void) => React.ReactNode /** Export drawer props */ exportOpen?: boolean onExportOpenChange?: (open: boolean) => void /** Row count for export; if omitted, uses `getTabCount(activeTab.filterId)` when provided */ exportTotalRows?: number /** * Tab menu — “Edit” (e.g. open table properties). Parent can switch to table view first, then call ref. * Overrides `tablePropertiesRef` when both are set. */ onEditView?: (tab: ViewTab, helpers: { updateTab: (patch: Partial) => void }) => void /** * Ref to the active tab’s table surface (`openPropertiesDrawer`). Wires “View → Edit” to * `TablePropertiesDrawer` when `onEditView` is omitted. */ tablePropertiesRef?: React.RefObject /** When true, hide the views tab strip (tabs + add view) — e.g. search landing with a single table surface. */ hideViewsToolbar?: boolean /** * Subset of view types the hub actually implements (e.g. List hub omits Dashboard/Folder). * When set, the Add view menu and ⌘1–9 shortcuts are filtered so users cannot add a view the * hub cannot render. When omitted, defaults to {@link TABLE_ONLY_HUB_SUPPORTED_VIEWS} * (table only). Pass {@link FULL_HUB_SUPPORTED_VIEWS} when the product asks for Add view parity. * (table, list, board, dashboard). Pass an explicit allowlist for specialized hubs * (e.g. tokens table-only, library with folder/calendar). * * Pair with `TablePropertiesDrawerButton.supportedViewTypes` to keep Properties consistent. */ supportedViewTypes?: readonly DataListViewType[] /** * **Opt-in localStorage persistence** for the page-level state: the * user's view tabs (their labels, view types, icons, filterIds) and the * currently active tab. When set, returning to the hub restores the * tab arrangement. * * Storage keys: `exxat-ds::listpage:tabs:v1` and * `exxat-ds::listpage:active-tab:v1`. **Use the same key * across renders** of the same hub or persistence will reset. * * When `tabs` / `activeTabId` are also passed in controlled mode, the * controlled props win — `persistKey` is ignored. Most hubs SHOULD pick * one or the other. * * @see `apps/web/docs/persisted-state-pattern.md` * @see `.cursor/rules/exxat-persisted-state.mdc` */ persistKey?: string /** * Whether count badges should be shown in the view tab bar. * This is a **hub-level** preference (affects all tabs). * When `persistKey` is provided and the prop is not controlled, the value * is persisted under `:listpage:show-view-counts:v1`. */ showViewCounts?: boolean onShowViewCountsChange?: (show: boolean) => void /** * Called when the user selects a view type from the "Add view" picker. * The parent (HubTable / LibraryTable) is expected to open * `TablePropertiesDrawer` in creation mode (`isCreatingNewView`) for that type, * allowing the user to set name + relevant initial view settings before the * tab is actually created. */ onRequestCreateView?: (type: ViewType) => void } /** Collision-proof id for a dynamically-added tab. Module-level counters reset * on HMR while React state survives, so we derive from a timestamp + random. */ function makeTabId(type: string): string { const rand = Math.random().toString(36).slice(2, 8) return `${type}-${Date.now().toString(36)}-${rand}` } /** * Wraps a control in a `Tip` only when there is something the control is not * already saying. A tab showing its label does not need a tooltip repeating it. */ function withTip(control: React.ReactElement, label: string | null) { if (!label) return control return ( {control} ) } /** Count pill on the views toolbar — color by lifecycle/status filter (WCAG: dark text on light inactive; light text on solid active). */ function viewToolbarCountBadgeClass(filterId: string, isActive: boolean): string { const palettes: Record = { all: { active: "bg-slate-600 text-white dark:bg-slate-500", inactive: "bg-slate-100 text-slate-800 dark:bg-slate-800/70 dark:text-slate-100", }, upcoming: { active: "bg-amber-600 text-white", inactive: "bg-amber-100 text-amber-950 dark:bg-amber-950/45 dark:text-amber-100", }, ongoing: { active: "bg-blue-600 text-white", inactive: "bg-blue-100 text-blue-950 dark:bg-blue-950/45 dark:text-blue-100", }, completed: { active: "bg-emerald-600 text-white", inactive: "bg-emerald-100 text-emerald-950 dark:bg-emerald-950/45 dark:text-emerald-100", }, } const p = palettes[filterId] ?? palettes.all return isActive ? p.active : p.inactive } // ───────────────────────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────────────────────── function ListPageTabBody({ tab, renderContent, updateTab, }: { tab: ViewTab renderContent: (tab: ViewTab, updateTab: (patch: Partial) => void) => React.ReactNode updateTab: (id: string, patch: Partial) => void }) { return <>{renderContent(tab, patch => updateTab(tab.id, patch))} } export function ListPageTemplate({ header, metrics, defaultShowMetrics = true, showMetrics: showMetricsProp, onShowMetricsChange, defaultTabs, tabs: tabsProp, onTabsChange, activeTabId: activeTabIdProp, onActiveTabChange, getTabCount, renderContent, exportOpen = false, onExportOpenChange, exportTotalRows = 0, onEditView, tablePropertiesRef, hideViewsToolbar = false, supportedViewTypes, persistKey, showViewCounts: showViewCountsProp, onShowViewCountsChange, onRequestCreateView, }: ListPageTemplateProps) { const [exportMounted, setExportMounted] = React.useState(false) if (exportOpen && !exportMounted) setExportMounted(true) // When a hub declares the views it implements, both the Add view menu and ⌘1–9 shortcut // bindings are filtered to that subset so unsupported views are never offered. Without // this, a user could ⌘1 → Dashboard into a hub that cannot render it (would show the // not-configured empty state). Memoized because it filters on every render path. const addableViewTypes = React.useMemo(() => { const allowlist = supportedViewTypes && supportedViewTypes.length > 0 ? supportedViewTypes : TABLE_ONLY_HUB_SUPPORTED_VIEWS const allowed = new Set(allowlist) return VIEW_TYPES.filter(v => allowed.has(v.type)) }, [supportedViewTypes]) const controlled = tabsProp !== undefined && onTabsChange !== undefined && activeTabIdProp !== undefined && onActiveTabChange !== undefined // Internal tabs/active-tab state, optionally backed by `localStorage` when // `persistKey` is set AND the consumer is NOT in controlled mode. // - `usePersistedState` accepts an empty key as "disabled, behave like // useState", so we always call it once (rules of hooks) and toggle // storage by toggling the key string. // - The keys are `:listpage:tabs:v1` and // `:listpage:active-tab:v1`. Hubs that move forward between // versions of `defaultTabs` should bump `persistKey`. const tabsStorageKey = persistKey && !controlled ? `${persistKey}:listpage:tabs:v1` : "" const activeStorageKey = persistKey && !controlled ? `${persistKey}:listpage:active-tab:v1` : "" const showCountsStorageKey = persistKey && showViewCountsProp === undefined && onShowViewCountsChange === undefined ? `${persistKey}:listpage:show-view-counts:v1` : "" const metricsStorageKey = persistKey && showMetricsProp === undefined && onShowMetricsChange === undefined ? `${persistKey}:listpage:show-metrics:v1` : "" const [internalTabs, setInternalTabs] = usePersistedState( tabsStorageKey, defaultTabs, ) const [internalActiveId, setInternalActiveId] = usePersistedState( activeStorageKey, defaultTabs[0]?.id ?? "", ) // Hub-level "show counts in the tab bar" — persisted when a persistKey is // present and the consumer does not control it explicitly. const [internalShowViewCounts] = usePersistedState( showCountsStorageKey, true, // default: show counts ) const [internalShowMetrics] = usePersistedState( metricsStorageKey, defaultShowMetrics, ) const tabs = controlled ? tabsProp : internalTabs const setTabsState = React.useCallback( (action: React.SetStateAction) => { if (controlled) { const next = typeof action === "function" ? action(tabsProp!) : action onTabsChange!(next) } else { setInternalTabs(action) } }, [controlled, onTabsChange, tabsProp, setInternalTabs], ) const activeTabId = controlled ? activeTabIdProp : internalActiveId const setActiveTabId = controlled ? onActiveTabChange : setInternalActiveId const showViewCounts = showViewCountsProp !== undefined ? showViewCountsProp : showCountsStorageKey ? internalShowViewCounts : true const showMetrics = showMetricsProp !== undefined ? showMetricsProp : metricsStorageKey ? internalShowMetrics : defaultShowMetrics const [renameOpen, setRenameOpen] = React.useState(false) const [renameValue, setRenameValue] = React.useState("") const renameTabIdRef = React.useRef(null) const [reviewOpen, setReviewOpen] = React.useState(false) const [reviewTab, setReviewTab] = React.useState(null) const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0] /** * Views share the page scrollport and have wildly different lengths — a table * of 400 rows, a dashboard of four charts — so each keeps its own place rather * than inheriting an offset that means nothing in the view being opened. */ const [root, setRoot] = React.useState(null) useRememberedPageScroll(activeTab?.id, { node: root, panelSelector: '[data-slot="list-page-view-body"]', }) const tabBodyFillsViewport = activeTab ? LIST_PAGE_VIEWPORT_FILL_VIEWS.has(activeTab.viewType) : false const resolvedSupportedViews = supportedViewTypes && supportedViewTypes.length > 0 ? supportedViewTypes : TABLE_ONLY_HUB_SUPPORTED_VIEWS /** Hide lifecycle/Add-view toolbar when table-only + single tab (opt-in multi-view). */ const showViewsToolbar = !hideViewsToolbar && (tabs.length > 1 || resolvedSupportedViews.length > 1) const viewsViewportRef = React.useRef(null) const canShedViews = showViewsToolbar && tabs.length > 1 /** * Prices rung 1 for the views row, the same way `Tabs` does for a tablist. * Without this the row only knows how to trade *every* inactive label for its * icon at once, so a row a few pixels short read as a row of glyphs — and, * because the all-or-nothing rung records the width it failed at, it stayed * that way even after the row had room again. */ const measureViewsRow = React.useCallback((): RowFitMeasurement | null => { const viewport = viewsViewportRef.current const content = viewport?.firstElementChild as HTMLElement | null if (!viewport || !content) return null return { available: viewport.clientWidth - horizontalPadding(viewport), // The toolbar clamps the same way the row does, so sum it rather than // measure it. content: naturalRowWidth(content, "[data-slot='view-segmented-toolbar']"), labels: measureRowLabels( content, label => label.dataset.viewTabPinned === "true", ), } }, []) const { openLabels: openViewLabels, visible: visibleViewCount } = useRowFitLadder({ viewportRef: viewsViewportRef, count: tabs.length, // A lone view has nothing to compare its glyph against, and it is the // selected one anyway — never worth reducing to an icon. canCollapse: tabs.length > 1, enabled: showViewsToolbar, measure: measureViewsRow, }) let shownViews = tabs let hiddenViews: typeof tabs = [] if (canShedViews && visibleViewCount < tabs.length) { shownViews = tabs.slice(0, visibleViewCount) hiddenViews = tabs.slice(visibleViewCount) if (activeTabId && !shownViews.some(t => t.id === activeTabId)) { const activeView = hiddenViews.find(t => t.id === activeTabId) if (activeView) { shownViews = [...shownViews.slice(0, -1), activeView] const onRow = new Set(shownViews.map(t => t.id)) hiddenViews = tabs.filter(t => !onRow.has(t.id)) } } } const editViewFromRef = React.useMemo( () => (tablePropertiesRef ? createListPageEditViewHandler(tablePropertiesRef) : undefined), [tablePropertiesRef] ) const resolvedOnEditView = onEditView ?? editViewFromRef function addView(type: ViewType) { const def = VIEW_TYPES.find(d => d.type === type)! const count = tabs.filter(t => t.viewType === type).length const id = makeTabId(type) const label = count === 0 ? def.label : `${def.label} ${count + 1}` const newTab: ViewTab = { id, label, viewType: type, icon: def.icon, filterId: "all" } setTabsState(prev => [...prev, newTab]) setActiveTabId(id) } function removeTab(id: string, e: React.MouseEvent | React.KeyboardEvent) { e.stopPropagation() setTabsState(prev => { const next = prev.filter(t => t.id !== id) if (activeTabId === id && next.length > 0) { const idx = Math.max(0, prev.findIndex(t => t.id === id) - 1) setActiveTabId(next[Math.min(idx, next.length - 1)].id) } return next }) } function updateTab(id: string, patch: Partial) { setTabsState(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t)) } function duplicateTab(tab: ViewTab) { const id = makeTabId(tab.viewType) const newTab: ViewTab = { id, label: `Copy of ${tab.label}`, viewType: tab.viewType, icon: tab.icon, filterId: tab.filterId, rowHeight: tab.rowHeight, } setTabsState(prev => [...prev, newTab]) setActiveTabId(id) } function openRename(tab: ViewTab) { renameTabIdRef.current = tab.id setRenameValue(tab.label) setRenameOpen(true) } function commitRename() { const tabId = renameTabIdRef.current if (!tabId) return const v = renameValue.trim() if (v) updateTab(tabId, { label: v }) setRenameOpen(false) renameTabIdRef.current = null } return ( <> {showViewsToolbar && addableViewTypes.slice(0, 9).map((v, i) => { const keys = dataListViewAddShortcut(i) return keys ? ( addView(v.type)} /> ) : null })} {activeTab && showViewsToolbar && ( <> openRename(activeTab)} /> resolvedOnEditView?.(activeTab, { updateTab: p => updateTab(activeTab.id, p) })} /> duplicateTab(activeTab)} /> { setReviewTab(activeTab); setReviewOpen(true) }} /> removeTab(activeTab.id, e as unknown as React.KeyboardEvent)} /> )} {/* Block wrapper (not a flex column): sticky view tabs must not be flex items of `PrimaryPageTemplate`'s content column — stretch/overflow interactions made Tokens and other hubs lose sticky while Library looked fine. */}
{header ?
{header}
: null} {showMetrics && metrics ? ( /* Above the views toolbar so KPI chart tooltips are not covered while metrics are on-screen. */
{metrics}
) : null} {/* ── Views toolbar (not tablist: settings/close are not tabs — WCAG 1.3.1 / ARIA) ── */} {showViewsToolbar && ( <> {/* Sticky layer 2 under the utility bar (layer 1 sits outside `[data-page-scroll]`). Table column headers (layer 3) pin to the bottom of this strip via `getStickyTableHeaderOffset`. Height matches `--shell-utility-bar-height`. Gap above the strip is a scrolling spacer — never `mt-*` on the sticky node (Chrome leaves `rect.top` below the pin line and the table head offset skips this strip, so the floating thead parks mid-canvas). */}