@exxatdesignux/ui 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- package/template/vitest.config.ts +18 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ListPageTemplate — reusable template for any list-based page.
|
|
5
|
+
*
|
|
6
|
+
* Provides: page header slot, optional key metrics, tabbed views
|
|
7
|
+
* (table/list/board/dashboard) with add/remove/configure per-tab,
|
|
8
|
+
* and an export drawer.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <ListPageTemplate
|
|
12
|
+
* header={<MyPageHeader />}
|
|
13
|
+
* metrics={<KeyMetrics ... />}
|
|
14
|
+
* defaultTabs={DEFAULT_TABS}
|
|
15
|
+
* renderContent={(tab) => <MyTable members={MOCK_ROWS} view={tab.viewType} />}
|
|
16
|
+
* />
|
|
17
|
+
*
|
|
18
|
+
* Connected views (table | list | board | dashboard) must share one `useTableState`
|
|
19
|
+
* and pass `tableState.rows` into non-table surfaces — see `docs/data-views-pattern.md`
|
|
20
|
+
* and `AGENTS.md` §4.
|
|
21
|
+
*
|
|
22
|
+
* View chrome is shared with `ViewSegmentedControl` / `viewSegmentedToolbarClass` in
|
|
23
|
+
* `@/components/ui/view-segmented-control` and re-exported from `@/components/data-views`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as React from "react"
|
|
27
|
+
import { cn } from "@/lib/utils"
|
|
28
|
+
import { Tip } from "@/components/ui/tip"
|
|
29
|
+
import { ExportDrawer } from "@/components/export-drawer"
|
|
30
|
+
import { Button } from "@/components/ui/button"
|
|
31
|
+
import { Input } from "@/components/ui/input"
|
|
32
|
+
import {
|
|
33
|
+
Dialog,
|
|
34
|
+
DialogContent,
|
|
35
|
+
DialogDescription,
|
|
36
|
+
DialogFooter,
|
|
37
|
+
DialogHeader,
|
|
38
|
+
DialogTitle,
|
|
39
|
+
} from "@/components/ui/dialog"
|
|
40
|
+
import {
|
|
41
|
+
DropdownMenu,
|
|
42
|
+
DropdownMenuContent,
|
|
43
|
+
DropdownMenuItem,
|
|
44
|
+
DropdownMenuLabel,
|
|
45
|
+
DropdownMenuSeparator,
|
|
46
|
+
DropdownMenuTrigger,
|
|
47
|
+
Shortcut,
|
|
48
|
+
} from "@/components/ui/dropdown-menu"
|
|
49
|
+
import type { DataListViewType } from "@/lib/data-list-view"
|
|
50
|
+
import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
|
|
51
|
+
import {
|
|
52
|
+
createListPageEditViewHandler,
|
|
53
|
+
type OpenTablePropertiesHandle,
|
|
54
|
+
} from "@/lib/list-page-table-properties"
|
|
55
|
+
import {
|
|
56
|
+
viewSegmentedToolbarClass,
|
|
57
|
+
viewSegmentedButtonClass,
|
|
58
|
+
} from "@/components/ui/view-segmented-control"
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// Types
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export type ViewType = DataListViewType
|
|
65
|
+
|
|
66
|
+
/** Same labels/icons as Properties drawer `SelectionTileGrid` — single source in `DATA_LIST_VIEW_TILES`. */
|
|
67
|
+
export const VIEW_TYPES: { type: ViewType; label: string; icon: string }[] = DATA_LIST_VIEW_TILES.map(t => ({
|
|
68
|
+
type: t.value,
|
|
69
|
+
label: t.label,
|
|
70
|
+
icon: t.icon,
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
export interface FilterOption {
|
|
74
|
+
id: string
|
|
75
|
+
label: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ViewTab {
|
|
79
|
+
id: string
|
|
80
|
+
label: string
|
|
81
|
+
viewType: ViewType
|
|
82
|
+
icon: string
|
|
83
|
+
/** Optional filter key for lifecycle or category-based filtering */
|
|
84
|
+
filterId: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ListPageTemplateProps {
|
|
88
|
+
/** Page header — rendered above metrics */
|
|
89
|
+
header: React.ReactNode
|
|
90
|
+
/** Optional metrics strip — rendered below header */
|
|
91
|
+
metrics?: React.ReactNode
|
|
92
|
+
/** Whether to show metrics (controlled externally) */
|
|
93
|
+
showMetrics?: boolean
|
|
94
|
+
/** Initial tabs (uncontrolled mode) */
|
|
95
|
+
defaultTabs: ViewTab[]
|
|
96
|
+
/**
|
|
97
|
+
* Controlled tabs — when all four are provided, tab state is owned by the parent
|
|
98
|
+
* (e.g. for localStorage). Otherwise `defaultTabs` + internal state are used.
|
|
99
|
+
*/
|
|
100
|
+
tabs?: ViewTab[]
|
|
101
|
+
onTabsChange?: (tabs: ViewTab[]) => void
|
|
102
|
+
activeTabId?: string
|
|
103
|
+
onActiveTabChange?: (id: string) => void
|
|
104
|
+
/** Filter options per tab (e.g. All, Upcoming, Ongoing, Completed) */
|
|
105
|
+
filterOptions?: FilterOption[]
|
|
106
|
+
/** Label for the filter sub-menu (default: "Filter") */
|
|
107
|
+
filterLabel?: string
|
|
108
|
+
/** Get count for a tab's filter (for badge) */
|
|
109
|
+
getTabCount?: (filterId: string) => number
|
|
110
|
+
/** Render the content for the active tab */
|
|
111
|
+
renderContent: (tab: ViewTab, updateTab: (patch: Partial<ViewTab>) => void) => React.ReactNode
|
|
112
|
+
/** Export drawer props */
|
|
113
|
+
exportOpen?: boolean
|
|
114
|
+
onExportOpenChange?: (open: boolean) => void
|
|
115
|
+
/** Row count for export; if omitted, uses `getTabCount(activeTab.filterId)` when provided */
|
|
116
|
+
exportTotalRows?: number
|
|
117
|
+
/**
|
|
118
|
+
* Tab menu — “Edit” (e.g. open table properties). Parent can switch to table view first, then call ref.
|
|
119
|
+
* Overrides `tablePropertiesRef` when both are set.
|
|
120
|
+
*/
|
|
121
|
+
onEditView?: (tab: ViewTab, helpers: { updateTab: (patch: Partial<ViewTab>) => void }) => void
|
|
122
|
+
/**
|
|
123
|
+
* Ref to the active tab’s table surface (`openPropertiesDrawer`). Wires “View → Edit” to
|
|
124
|
+
* `TablePropertiesDrawer` when `onEditView` is omitted.
|
|
125
|
+
*/
|
|
126
|
+
tablePropertiesRef?: React.RefObject<OpenTablePropertiesHandle | null>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Collision-proof id for a dynamically-added tab. Module-level counters reset
|
|
130
|
+
* on HMR while React state survives, so we derive from a timestamp + random. */
|
|
131
|
+
function makeTabId(type: string): string {
|
|
132
|
+
const rand = Math.random().toString(36).slice(2, 8)
|
|
133
|
+
return `${type}-${Date.now().toString(36)}-${rand}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Count pill on the views toolbar — color by lifecycle/status filter (WCAG: dark text on light inactive; light text on solid active). */
|
|
137
|
+
export function viewToolbarCountBadgeClass(filterId: string, isActive: boolean): string {
|
|
138
|
+
const palettes: Record<string, { active: string; inactive: string }> = {
|
|
139
|
+
all: {
|
|
140
|
+
active: "bg-slate-600 text-white dark:bg-slate-500",
|
|
141
|
+
inactive: "bg-slate-100 text-slate-800 dark:bg-slate-800/70 dark:text-slate-100",
|
|
142
|
+
},
|
|
143
|
+
upcoming: {
|
|
144
|
+
active: "bg-amber-600 text-white",
|
|
145
|
+
inactive: "bg-amber-100 text-amber-950 dark:bg-amber-950/45 dark:text-amber-100",
|
|
146
|
+
},
|
|
147
|
+
ongoing: {
|
|
148
|
+
active: "bg-blue-600 text-white",
|
|
149
|
+
inactive: "bg-blue-100 text-blue-950 dark:bg-blue-950/45 dark:text-blue-100",
|
|
150
|
+
},
|
|
151
|
+
completed: {
|
|
152
|
+
active: "bg-emerald-600 text-white",
|
|
153
|
+
inactive: "bg-emerald-100 text-emerald-950 dark:bg-emerald-950/45 dark:text-emerald-100",
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
const p = palettes[filterId] ?? palettes.all
|
|
157
|
+
return isActive ? p.active : p.inactive
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// Component
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export function ListPageTemplate({
|
|
165
|
+
header,
|
|
166
|
+
metrics,
|
|
167
|
+
showMetrics = true,
|
|
168
|
+
defaultTabs,
|
|
169
|
+
tabs: tabsProp,
|
|
170
|
+
onTabsChange,
|
|
171
|
+
activeTabId: activeTabIdProp,
|
|
172
|
+
onActiveTabChange,
|
|
173
|
+
getTabCount,
|
|
174
|
+
renderContent,
|
|
175
|
+
exportOpen = false,
|
|
176
|
+
onExportOpenChange,
|
|
177
|
+
exportTotalRows = 0,
|
|
178
|
+
onEditView,
|
|
179
|
+
tablePropertiesRef,
|
|
180
|
+
}: ListPageTemplateProps) {
|
|
181
|
+
const controlled =
|
|
182
|
+
tabsProp !== undefined &&
|
|
183
|
+
onTabsChange !== undefined &&
|
|
184
|
+
activeTabIdProp !== undefined &&
|
|
185
|
+
onActiveTabChange !== undefined
|
|
186
|
+
|
|
187
|
+
const [internalTabs, setInternalTabs] = React.useState<ViewTab[]>(defaultTabs)
|
|
188
|
+
const [internalActiveId, setInternalActiveId] = React.useState(defaultTabs[0]?.id ?? "")
|
|
189
|
+
|
|
190
|
+
const tabs = controlled ? tabsProp : internalTabs
|
|
191
|
+
const setTabsState = React.useCallback(
|
|
192
|
+
(action: React.SetStateAction<ViewTab[]>) => {
|
|
193
|
+
if (controlled) {
|
|
194
|
+
const next = typeof action === "function" ? action(tabsProp!) : action
|
|
195
|
+
onTabsChange!(next)
|
|
196
|
+
} else {
|
|
197
|
+
setInternalTabs(action)
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[controlled, onTabsChange, tabsProp, setInternalTabs],
|
|
201
|
+
)
|
|
202
|
+
const activeTabId = controlled ? activeTabIdProp : internalActiveId
|
|
203
|
+
const setActiveTabId = controlled ? onActiveTabChange : setInternalActiveId
|
|
204
|
+
const [renameOpen, setRenameOpen] = React.useState(false)
|
|
205
|
+
const [renameValue, setRenameValue] = React.useState("")
|
|
206
|
+
const [renameTabId, setRenameTabId] = React.useState<string | null>(null)
|
|
207
|
+
const [reviewOpen, setReviewOpen] = React.useState(false)
|
|
208
|
+
const [reviewTab, setReviewTab] = React.useState<ViewTab | null>(null)
|
|
209
|
+
|
|
210
|
+
const activeTab = tabs.find(t => t.id === activeTabId) ?? tabs[0]
|
|
211
|
+
|
|
212
|
+
const editViewFromRef = React.useMemo(
|
|
213
|
+
() => (tablePropertiesRef ? createListPageEditViewHandler(tablePropertiesRef) : undefined),
|
|
214
|
+
[tablePropertiesRef]
|
|
215
|
+
)
|
|
216
|
+
const resolvedOnEditView = onEditView ?? editViewFromRef
|
|
217
|
+
|
|
218
|
+
function addView(type: ViewType) {
|
|
219
|
+
const def = VIEW_TYPES.find(d => d.type === type)!
|
|
220
|
+
const count = tabs.filter(t => t.viewType === type).length
|
|
221
|
+
const id = makeTabId(type)
|
|
222
|
+
const label = count === 0 ? def.label : `${def.label} ${count + 1}`
|
|
223
|
+
const newTab: ViewTab = { id, label, viewType: type, icon: def.icon, filterId: "all" }
|
|
224
|
+
setTabsState(prev => [...prev, newTab])
|
|
225
|
+
setActiveTabId(id)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function removeTab(id: string, e: React.MouseEvent | React.KeyboardEvent) {
|
|
229
|
+
e.stopPropagation()
|
|
230
|
+
setTabsState(prev => {
|
|
231
|
+
const next = prev.filter(t => t.id !== id)
|
|
232
|
+
if (activeTabId === id && next.length > 0) {
|
|
233
|
+
const idx = Math.max(0, prev.findIndex(t => t.id === id) - 1)
|
|
234
|
+
setActiveTabId(next[Math.min(idx, next.length - 1)].id)
|
|
235
|
+
}
|
|
236
|
+
return next
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function updateTab(id: string, patch: Partial<ViewTab>) {
|
|
241
|
+
setTabsState(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function duplicateTab(tab: ViewTab) {
|
|
245
|
+
const id = makeTabId(tab.viewType)
|
|
246
|
+
const newTab: ViewTab = {
|
|
247
|
+
id,
|
|
248
|
+
label: `Copy of ${tab.label}`,
|
|
249
|
+
viewType: tab.viewType,
|
|
250
|
+
icon: tab.icon,
|
|
251
|
+
filterId: tab.filterId,
|
|
252
|
+
}
|
|
253
|
+
setTabsState(prev => [...prev, newTab])
|
|
254
|
+
setActiveTabId(id)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function openRename(tab: ViewTab) {
|
|
258
|
+
setRenameTabId(tab.id)
|
|
259
|
+
setRenameValue(tab.label)
|
|
260
|
+
setRenameOpen(true)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function commitRename() {
|
|
264
|
+
if (!renameTabId) return
|
|
265
|
+
const v = renameValue.trim()
|
|
266
|
+
if (v) updateTab(renameTabId, { label: v })
|
|
267
|
+
setRenameOpen(false)
|
|
268
|
+
setRenameTabId(null)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<>
|
|
273
|
+
{VIEW_TYPES.slice(0, 9).map((v, i) => (
|
|
274
|
+
<Shortcut
|
|
275
|
+
key={v.type}
|
|
276
|
+
keys={`⌘⇧${i + 1}`}
|
|
277
|
+
onInvoke={() => addView(v.type)}
|
|
278
|
+
/>
|
|
279
|
+
))}
|
|
280
|
+
{activeTab && (
|
|
281
|
+
<>
|
|
282
|
+
<Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
|
|
283
|
+
<Shortcut
|
|
284
|
+
keys="⌘E"
|
|
285
|
+
disabled={!resolvedOnEditView}
|
|
286
|
+
onInvoke={() => resolvedOnEditView?.(activeTab, { updateTab: p => updateTab(activeTab.id, p) })}
|
|
287
|
+
/>
|
|
288
|
+
<Shortcut keys="⌘D" onInvoke={() => duplicateTab(activeTab)} />
|
|
289
|
+
<Shortcut keys="⌘I" onInvoke={() => { setReviewTab(activeTab); setReviewOpen(true) }} />
|
|
290
|
+
<Shortcut
|
|
291
|
+
keys="⌘⌫"
|
|
292
|
+
disabled={tabs.length <= 1}
|
|
293
|
+
onInvoke={(e) => removeTab(activeTab.id, e as unknown as React.KeyboardEvent)}
|
|
294
|
+
/>
|
|
295
|
+
</>
|
|
296
|
+
)}
|
|
297
|
+
{header}
|
|
298
|
+
|
|
299
|
+
{showMetrics && metrics}
|
|
300
|
+
|
|
301
|
+
{/* ── Views toolbar (not tablist: settings/close are not tabs — WCAG 1.3.1 / ARIA) ── */}
|
|
302
|
+
{/* Outer: horizontal scroll only. Inner: vertical padding so ring-offset focus rings are not clipped
|
|
303
|
+
(`overflow-x-auto` forces overflow-y to clip in a single box). */}
|
|
304
|
+
<div className="mt-3 shrink-0 overflow-x-auto px-4 lg:px-6">
|
|
305
|
+
<div className="flex w-max items-center gap-1 py-1.5">
|
|
306
|
+
<div
|
|
307
|
+
role="toolbar"
|
|
308
|
+
aria-label="Views"
|
|
309
|
+
data-slot="view-segmented-toolbar"
|
|
310
|
+
className={viewSegmentedToolbarClass()}
|
|
311
|
+
>
|
|
312
|
+
{tabs.map(tab => {
|
|
313
|
+
const isActive = tab.id === activeTabId
|
|
314
|
+
const isOnly = tabs.length === 1
|
|
315
|
+
const count = getTabCount?.(tab.filterId)
|
|
316
|
+
const tabInner = (
|
|
317
|
+
<>
|
|
318
|
+
{isActive ? (
|
|
319
|
+
<i className={`fa-light ${tab.icon} text-xs`} aria-hidden="true" />
|
|
320
|
+
) : null}
|
|
321
|
+
{tab.label}
|
|
322
|
+
{count !== undefined && (
|
|
323
|
+
<span
|
|
324
|
+
data-slot="view-toolbar-count"
|
|
325
|
+
className={cn(
|
|
326
|
+
"text-xs tabular-nums rounded-full px-1 py-px min-w-[1.125rem] text-center font-semibold",
|
|
327
|
+
viewToolbarCountBadgeClass(tab.filterId, isActive),
|
|
328
|
+
)}
|
|
329
|
+
>
|
|
330
|
+
{count}
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
</>
|
|
334
|
+
)
|
|
335
|
+
const viewSettingsMenu = (
|
|
336
|
+
<DropdownMenu>
|
|
337
|
+
<Tip label="View settings" side="bottom">
|
|
338
|
+
<DropdownMenuTrigger asChild>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
className={cn(
|
|
342
|
+
"inline-flex items-center justify-center min-h-8 min-w-6 shrink-0 rounded-r-md rounded-l-none px-0.5",
|
|
343
|
+
"text-muted-foreground hover:text-interactive-hover-foreground hover:bg-foreground/[0.04]",
|
|
344
|
+
"transition-colors",
|
|
345
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:z-10",
|
|
346
|
+
)}
|
|
347
|
+
aria-label="View settings"
|
|
348
|
+
>
|
|
349
|
+
<i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
|
|
350
|
+
</button>
|
|
351
|
+
</DropdownMenuTrigger>
|
|
352
|
+
</Tip>
|
|
353
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
354
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
355
|
+
View: {VIEW_TYPES.find(v => v.type === tab.viewType)?.label}
|
|
356
|
+
</DropdownMenuLabel>
|
|
357
|
+
<DropdownMenuSeparator />
|
|
358
|
+
|
|
359
|
+
<DropdownMenuItem
|
|
360
|
+
shortcut="F2"
|
|
361
|
+
onSelect={() => openRename(tab)}
|
|
362
|
+
>
|
|
363
|
+
<i className="fa-light fa-pen text-xs" aria-hidden="true" />
|
|
364
|
+
Rename
|
|
365
|
+
</DropdownMenuItem>
|
|
366
|
+
<DropdownMenuItem
|
|
367
|
+
disabled={!resolvedOnEditView}
|
|
368
|
+
shortcut="⌘E"
|
|
369
|
+
onSelect={() =>
|
|
370
|
+
resolvedOnEditView?.(tab, { updateTab: patch => updateTab(tab.id, patch) })
|
|
371
|
+
}
|
|
372
|
+
>
|
|
373
|
+
<i className="fa-light fa-sliders text-xs" aria-hidden="true" />
|
|
374
|
+
Edit
|
|
375
|
+
</DropdownMenuItem>
|
|
376
|
+
<DropdownMenuItem shortcut="⌘D" onSelect={() => duplicateTab(tab)}>
|
|
377
|
+
<i className="fa-light fa-copy text-xs" aria-hidden="true" />
|
|
378
|
+
Duplicate
|
|
379
|
+
</DropdownMenuItem>
|
|
380
|
+
<DropdownMenuItem
|
|
381
|
+
shortcut="⌘I"
|
|
382
|
+
onSelect={() => { setReviewTab(tab); setReviewOpen(true) }}
|
|
383
|
+
>
|
|
384
|
+
<i className="fa-light fa-clipboard-list text-xs" aria-hidden="true" />
|
|
385
|
+
Review view
|
|
386
|
+
</DropdownMenuItem>
|
|
387
|
+
|
|
388
|
+
<DropdownMenuSeparator />
|
|
389
|
+
{!isOnly && (
|
|
390
|
+
<DropdownMenuItem
|
|
391
|
+
shortcut="⌘⌫"
|
|
392
|
+
onSelect={(e) => removeTab(tab.id, e as unknown as React.KeyboardEvent)}
|
|
393
|
+
className="text-destructive focus:text-destructive"
|
|
394
|
+
>
|
|
395
|
+
<i className="fa-light fa-trash text-xs" aria-hidden="true" />
|
|
396
|
+
Remove view
|
|
397
|
+
</DropdownMenuItem>
|
|
398
|
+
)}
|
|
399
|
+
</DropdownMenuContent>
|
|
400
|
+
</DropdownMenu>
|
|
401
|
+
)
|
|
402
|
+
return (
|
|
403
|
+
<div key={tab.id} className="group relative inline-flex items-center">
|
|
404
|
+
{isActive ? (
|
|
405
|
+
<div
|
|
406
|
+
className={cn(
|
|
407
|
+
viewSegmentedButtonClass(true),
|
|
408
|
+
"gap-0 p-0 inline-flex items-stretch",
|
|
409
|
+
)}
|
|
410
|
+
>
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
aria-pressed={true}
|
|
414
|
+
data-slot="view-segmented-item"
|
|
415
|
+
onClick={() => setActiveTabId(tab.id)}
|
|
416
|
+
className={cn(
|
|
417
|
+
"inline-flex items-center gap-1.5 pl-2.5 pr-0.5 py-1 text-xs min-h-8",
|
|
418
|
+
"rounded-l-md rounded-r-none",
|
|
419
|
+
"bg-transparent text-foreground font-medium",
|
|
420
|
+
"hover:bg-foreground/[0.04]",
|
|
421
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:z-10",
|
|
422
|
+
)}
|
|
423
|
+
>
|
|
424
|
+
{tabInner}
|
|
425
|
+
</button>
|
|
426
|
+
{viewSettingsMenu}
|
|
427
|
+
</div>
|
|
428
|
+
) : (
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
aria-pressed={false}
|
|
432
|
+
data-slot="view-segmented-item"
|
|
433
|
+
onClick={() => setActiveTabId(tab.id)}
|
|
434
|
+
className={cn(
|
|
435
|
+
viewSegmentedButtonClass(false),
|
|
436
|
+
/* Tighter trailing edge when remove control follows */
|
|
437
|
+
!isOnly && "pr-1.5",
|
|
438
|
+
)}
|
|
439
|
+
>
|
|
440
|
+
{tabInner}
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
{/* Close on inactive tabs — native button + 24px min target (WCAG 2.5.8) */}
|
|
445
|
+
{!isActive && !isOnly && (
|
|
446
|
+
<Tip side="bottom" label={`Remove ${tab.label} view`}>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
aria-label={`Remove ${tab.label} view`}
|
|
450
|
+
onClick={e => removeTab(tab.id, e)}
|
|
451
|
+
className="inline-flex items-center justify-center size-6 min-h-6 min-w-6 rounded transition-opacity opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
452
|
+
>
|
|
453
|
+
<i className="fa-light fa-xmark text-xs" aria-hidden="true" />
|
|
454
|
+
</button>
|
|
455
|
+
</Tip>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
)
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
{/* Add view */}
|
|
463
|
+
<DropdownMenu>
|
|
464
|
+
<DropdownMenuTrigger asChild>
|
|
465
|
+
<Button
|
|
466
|
+
type="button"
|
|
467
|
+
variant="ghost"
|
|
468
|
+
className="shrink-0 text-muted-foreground"
|
|
469
|
+
>
|
|
470
|
+
<i className="fa-light fa-plus text-sm" aria-hidden="true" />
|
|
471
|
+
Add view
|
|
472
|
+
</Button>
|
|
473
|
+
</DropdownMenuTrigger>
|
|
474
|
+
<DropdownMenuContent align="start" className="w-40">
|
|
475
|
+
<DropdownMenuLabel className="text-xs">Add a view</DropdownMenuLabel>
|
|
476
|
+
<DropdownMenuSeparator />
|
|
477
|
+
{VIEW_TYPES.map((v, i) => (
|
|
478
|
+
<DropdownMenuItem
|
|
479
|
+
key={v.type}
|
|
480
|
+
shortcut={i < 9 ? `⌘⇧${i + 1}` : undefined}
|
|
481
|
+
onClick={() => addView(v.type)}
|
|
482
|
+
>
|
|
483
|
+
<i className={`fa-light ${v.icon}`} aria-hidden="true" />
|
|
484
|
+
{v.label}
|
|
485
|
+
</DropdownMenuItem>
|
|
486
|
+
))}
|
|
487
|
+
</DropdownMenuContent>
|
|
488
|
+
</DropdownMenu>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
{/* ── Content — keyed by tab so each view tab owns its height (no stale min-height). ── */}
|
|
493
|
+
{activeTab ? (
|
|
494
|
+
<div key={activeTab.id} className="flex min-h-0 flex-col">
|
|
495
|
+
{renderContent(activeTab, patch => updateTab(activeTab.id, patch))}
|
|
496
|
+
</div>
|
|
497
|
+
) : null}
|
|
498
|
+
|
|
499
|
+
{/* ── Export ──────────────────────────────────────────────── */}
|
|
500
|
+
{onExportOpenChange && (
|
|
501
|
+
<ExportDrawer
|
|
502
|
+
open={exportOpen}
|
|
503
|
+
onOpenChange={onExportOpenChange}
|
|
504
|
+
totalRows={exportTotalRows ?? getTabCount?.(activeTab.filterId) ?? 0}
|
|
505
|
+
/>
|
|
506
|
+
)}
|
|
507
|
+
|
|
508
|
+
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
|
509
|
+
<DialogContent className="max-w-sm">
|
|
510
|
+
<DialogHeader>
|
|
511
|
+
<DialogTitle>Rename view</DialogTitle>
|
|
512
|
+
<DialogDescription>Enter a new name for this view.</DialogDescription>
|
|
513
|
+
</DialogHeader>
|
|
514
|
+
<Input
|
|
515
|
+
className="mt-3 h-9 text-sm"
|
|
516
|
+
value={renameValue}
|
|
517
|
+
onChange={e => setRenameValue(e.target.value)}
|
|
518
|
+
onKeyDown={e => { if (e.key === "Enter") commitRename() }}
|
|
519
|
+
autoFocus
|
|
520
|
+
aria-label="View name"
|
|
521
|
+
/>
|
|
522
|
+
<DialogFooter>
|
|
523
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setRenameOpen(false)}>
|
|
524
|
+
Cancel
|
|
525
|
+
</Button>
|
|
526
|
+
<Button type="button" size="sm" onClick={commitRename}>
|
|
527
|
+
Save
|
|
528
|
+
</Button>
|
|
529
|
+
</DialogFooter>
|
|
530
|
+
</DialogContent>
|
|
531
|
+
</Dialog>
|
|
532
|
+
|
|
533
|
+
<Dialog
|
|
534
|
+
open={reviewOpen && !!reviewTab}
|
|
535
|
+
onOpenChange={(open) => {
|
|
536
|
+
setReviewOpen(open)
|
|
537
|
+
if (!open) setReviewTab(null)
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<DialogContent className="max-w-md">
|
|
541
|
+
<DialogHeader>
|
|
542
|
+
<DialogTitle>Review view</DialogTitle>
|
|
543
|
+
<DialogDescription>Summary of this view’s configuration.</DialogDescription>
|
|
544
|
+
</DialogHeader>
|
|
545
|
+
{reviewTab && (
|
|
546
|
+
<dl className="mt-2 space-y-3 text-sm">
|
|
547
|
+
<div className="flex justify-between gap-4 border-b border-border pb-2">
|
|
548
|
+
<dt className="text-muted-foreground">Name</dt>
|
|
549
|
+
<dd className="font-medium text-foreground text-end">{reviewTab.label}</dd>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="flex justify-between gap-4 border-b border-border pb-2">
|
|
552
|
+
<dt className="text-muted-foreground">View type</dt>
|
|
553
|
+
<dd className="text-foreground text-end">{VIEW_TYPES.find(v => v.type === reviewTab.viewType)?.label}</dd>
|
|
554
|
+
</div>
|
|
555
|
+
<div className="flex justify-between gap-4 border-b border-border pb-2">
|
|
556
|
+
<dt className="text-muted-foreground">Lifecycle filter</dt>
|
|
557
|
+
<dd className="text-foreground text-end capitalize">{reviewTab.filterId}</dd>
|
|
558
|
+
</div>
|
|
559
|
+
{getTabCount && (
|
|
560
|
+
<div className="flex justify-between gap-4">
|
|
561
|
+
<dt className="text-muted-foreground">Row count</dt>
|
|
562
|
+
<dd className="tabular-nums text-foreground text-end">{getTabCount(reviewTab.filterId)}</dd>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</dl>
|
|
566
|
+
)}
|
|
567
|
+
<DialogFooter>
|
|
568
|
+
<Button type="button" size="sm" onClick={() => setReviewOpen(false)}>
|
|
569
|
+
Close
|
|
570
|
+
</Button>
|
|
571
|
+
</DialogFooter>
|
|
572
|
+
</DialogContent>
|
|
573
|
+
</Dialog>
|
|
574
|
+
</>
|
|
575
|
+
)
|
|
576
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { SiteHeader, type SiteHeaderProps } from "@/components/site-header"
|
|
4
|
+
import { SidebarInset } from "@/components/ui/sidebar"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
/** Default max width for primary hub pages (Placements, Team, Compliance, …). */
|
|
8
|
+
export const PRIMARY_PAGE_MAX_WIDTH_CLASS = "max-w-[1440px]"
|
|
9
|
+
|
|
10
|
+
export interface PrimaryPageTemplateProps {
|
|
11
|
+
/** Optional chrome before `SiteHeader` (e.g. `RotationsPanelActivator`, `SidebarAutoCollapse`). */
|
|
12
|
+
beforeSiteHeader?: React.ReactNode
|
|
13
|
+
/** Top bar — breadcrumbs and Ask Leo. Omit for focused flows (e.g. full-page form with no chrome). */
|
|
14
|
+
siteHeader?: SiteHeaderProps
|
|
15
|
+
/** Page body — typically `*Client` with `ListPageTemplate` inside. */
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
/** Override default `max-w-[1440px]` for narrower shells (patterns showcase, detail routes). */
|
|
18
|
+
maxWidthClassName?: string
|
|
19
|
+
/** Extra classes on the `@[container]main` content column. */
|
|
20
|
+
contentClassName?: string
|
|
21
|
+
/** Extra classes on the flex wrapper between `SiteHeader` and the content column. */
|
|
22
|
+
bodyClassName?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Primary page shell — same composition as Placements / Team / Compliance routes:
|
|
27
|
+
* `SidebarInset` (single `main` landmark) + `SiteHeader` + max-width content column.
|
|
28
|
+
*
|
|
29
|
+
* Use with `ListPageTemplate` + data client per `AGENTS.md` §6.3 and `docs/data-views-pattern.md`.
|
|
30
|
+
*/
|
|
31
|
+
export function PrimaryPageTemplate({
|
|
32
|
+
beforeSiteHeader,
|
|
33
|
+
siteHeader,
|
|
34
|
+
children,
|
|
35
|
+
maxWidthClassName = PRIMARY_PAGE_MAX_WIDTH_CLASS,
|
|
36
|
+
contentClassName,
|
|
37
|
+
bodyClassName,
|
|
38
|
+
}: PrimaryPageTemplateProps) {
|
|
39
|
+
return (
|
|
40
|
+
<SidebarInset id="main-content" tabIndex={-1}>
|
|
41
|
+
{beforeSiteHeader}
|
|
42
|
+
{siteHeader ? <SiteHeader {...siteHeader} /> : null}
|
|
43
|
+
<div className={cn("flex min-h-0 flex-1 flex-col outline-none", bodyClassName)}>
|
|
44
|
+
<div
|
|
45
|
+
className={cn(
|
|
46
|
+
"@container/main mx-auto flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
|
47
|
+
maxWidthClassName,
|
|
48
|
+
contentClassName,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</SidebarInset>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useTheme } from "next-themes"
|
|
5
|
+
import { useProduct } from "@/contexts/product-context"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keeps `<meta name="theme-color">` in sync with `--theme-color-chrome` in globals.css
|
|
9
|
+
* (brand: theme-one vs theme-prism + light/dark from `html` + next-themes).
|
|
10
|
+
*/
|
|
11
|
+
export function ThemeColorSync() {
|
|
12
|
+
const { resolvedTheme } = useTheme()
|
|
13
|
+
const { product } = useProduct()
|
|
14
|
+
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
const raw = getComputedStyle(document.documentElement)
|
|
17
|
+
.getPropertyValue("--theme-color-chrome")
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/^["']|["']$/g, "")
|
|
20
|
+
if (!raw) return
|
|
21
|
+
|
|
22
|
+
let meta = document.querySelector('meta[name="theme-color"]')
|
|
23
|
+
if (!meta) {
|
|
24
|
+
meta = document.createElement("meta")
|
|
25
|
+
meta.setAttribute("name", "theme-color")
|
|
26
|
+
document.head.appendChild(meta)
|
|
27
|
+
}
|
|
28
|
+
meta.setAttribute("content", raw)
|
|
29
|
+
}, [resolvedTheme, product])
|
|
30
|
+
|
|
31
|
+
return null
|
|
32
|
+
}
|