"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).
*/}
{shownViews.map((tab, viewIndex) => {
const isActive = tab.id === activeTabId
const isOnly = tabs.length === 1
const count = getTabCount?.(tab.filterId)
// Labels close from the end of the row and only as far as they have
// to; the selected view is pinned and never closes.
const iconOnly = !isActive && viewIndex >= openViewLabels
const tabInner = (
<>
{/*
The text stays in the DOM rather than going `sr-only`, for two
reasons: the tab keeps its accessible name, and the ladder can
still read `scrollWidth` off it to price re-opening. What goes
away is the column it sits in — a grid track from `1fr` to
`0fr`, which unlike `display: none` is a length, and so can be
crossed rather than jumped.
*/}
{tab.label}
{/* Count badge is hub-level (showViewCounts) and only appears when
the consumer supplied a getTabCount function and the setting is on.
It rides inside the collapsible track so the ladder prices
the label and its count as the one thing they look like. */}
{showViewCounts && count !== undefined ? (
{count}
) : null}
>
)
const viewSettingsMenu = (
View: {VIEW_TYPES.find(v => v.type === tab.viewType)?.label}
openRename(tab)}
>
Rename
resolvedOnEditView?.(tab, { updateTab: patch => updateTab(tab.id, patch) })
}
>
Edit
duplicateTab(tab)}>
Duplicate
{ setReviewTab(tab); setReviewOpen(true) }}
>
Review view
{!isOnly && (
removeTab(tab.id, e as unknown as React.KeyboardEvent)}
className="text-destructive-ink focus:text-destructive-ink"
>
Remove view
)}
)
return (
{isActive ? (
{viewSettingsMenu}
) : (
/*
One button either way. The collapsed tab keeps the open
tab's padding and lets the label track carry the width
change, so closing a label is a transition rather than a
swap to a differently-shaped control.
*/
withTip(
,
iconOnly ? tab.label : null,
)
)}
{/* Close on inactive tabs — native button + 24px min target (WCAG 2.5.8) */}
{!isActive && !isOnly && (
)}
{/* Add view — always a glyph. It sits at the end of a rail of named
views, where a labelled button competes with them for the eye and
reads like one more view rather than the way to make one. The
plus is unambiguous in that position, and the Tip carries the
name for anyone who needs it. */}
Add a view
{addableViewTypes.map((v, i) => (
{
if (onRequestCreateView) {
onRequestCreateView(v.type)
} else {
// Backward-compat: immediate create if parent not wired yet
addView(v.type)
}
}}
>
{v.label}
))}
>
)}
{/* ── Content — keyed by tab so each view tab owns its height (no stale min-height).
Page-scroll views (table / list / board) grow with `[data-page-scroll]`.
Viewport-fill views (folder / panel / tree / dashboard) use flex-1 + internal pane scroll. ── */}
{activeTab ? (