@exxatdesignux/ui 0.0.6 → 0.0.8

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.
Files changed (264) hide show
  1. package/bin/init.mjs +29 -0
  2. package/package.json +7 -2
  3. package/template/.nvmrc +1 -0
  4. package/template/.prettierignore +7 -0
  5. package/template/.prettierrc +11 -0
  6. package/template/AGENTS.md +485 -0
  7. package/template/Logo/Exxat_Prism.svg +39 -0
  8. package/template/Logo/Exxat_one.svg +36 -0
  9. package/template/README.md +58 -0
  10. package/template/app/(app)/compliance/page.tsx +10 -0
  11. package/template/app/(app)/dashboard/loading.tsx +18 -0
  12. package/template/app/(app)/dashboard/page.tsx +36 -0
  13. package/template/app/(app)/data-list/[id]/page.tsx +28 -0
  14. package/template/app/(app)/data-list/new/page.tsx +31 -0
  15. package/template/app/(app)/data-list/page.tsx +10 -0
  16. package/template/app/(app)/error.tsx +43 -0
  17. package/template/app/(app)/help/page.tsx +34 -0
  18. package/template/app/(app)/layout.tsx +54 -0
  19. package/template/app/(app)/loading.tsx +18 -0
  20. package/template/app/(app)/question-bank/page.tsx +10 -0
  21. package/template/app/(app)/rotations/page.tsx +15 -0
  22. package/template/app/(app)/settings/page.tsx +17 -0
  23. package/template/app/(app)/sites/all/page.tsx +13 -0
  24. package/template/app/(app)/team/page.tsx +10 -0
  25. package/template/app/favicon.ico +0 -0
  26. package/template/app/globals.css +1811 -0
  27. package/template/app/layout.tsx +95 -0
  28. package/template/app/page.tsx +9 -0
  29. package/template/components/.gitkeep +0 -0
  30. package/template/components/app-sidebar-dynamic.tsx +15 -0
  31. package/template/components/app-sidebar.tsx +901 -0
  32. package/template/components/ask-leo-composer.tsx +216 -0
  33. package/template/components/ask-leo-sidebar.tsx +509 -0
  34. package/template/components/chart-area-interactive.tsx +293 -0
  35. package/template/components/charts-overview.tsx +2321 -0
  36. package/template/components/command-menu-01.tsx +133 -0
  37. package/template/components/command-menu-02.tsx +386 -0
  38. package/template/components/command-menu.tsx +182 -0
  39. package/template/components/compliance-board-view.tsx +134 -0
  40. package/template/components/compliance-client.tsx +92 -0
  41. package/template/components/compliance-list-view.tsx +59 -0
  42. package/template/components/compliance-page-header.tsx +89 -0
  43. package/template/components/compliance-table.tsx +525 -0
  44. package/template/components/dashboard-onboarding-gallery.tsx +13 -0
  45. package/template/components/dashboard-onboarding.tsx +21 -0
  46. package/template/components/dashboard-promo-banner.tsx +67 -0
  47. package/template/components/dashboard-quota-progress-card.tsx +369 -0
  48. package/template/components/dashboard-report-charts.tsx +69 -0
  49. package/template/components/dashboard-section-heading.tsx +68 -0
  50. package/template/components/dashboard-tabs.tsx +598 -0
  51. package/template/components/data-list-client.tsx +239 -0
  52. package/template/components/data-list-table-cells.test.tsx +22 -0
  53. package/template/components/data-list-table-cells.tsx +173 -0
  54. package/template/components/data-list-table.tsx +879 -0
  55. package/template/components/data-table/filter-date-calendar.tsx +38 -0
  56. package/template/components/data-table/filter-text-value-input.tsx +77 -0
  57. package/template/components/data-table/index.tsx +1612 -0
  58. package/template/components/data-table/pagination.tsx +256 -0
  59. package/template/components/data-table/types.ts +91 -0
  60. package/template/components/data-table/use-table-state.ts +566 -0
  61. package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
  62. package/template/components/data-view-dashboard-charts-team.tsx +968 -0
  63. package/template/components/data-view-dashboard-charts.tsx +1668 -0
  64. package/template/components/data-views/board-card-primitives.tsx +93 -0
  65. package/template/components/data-views/index.ts +41 -0
  66. package/template/components/data-views/list-page-board-card.tsx +192 -0
  67. package/template/components/data-views/list-page-board-template.tsx +122 -0
  68. package/template/components/data-views/placement-board-card.tsx +262 -0
  69. package/template/components/export-drawer.tsx +375 -0
  70. package/template/components/exxat-product-logo.tsx +453 -0
  71. package/template/components/form-layout-01.tsx +131 -0
  72. package/template/components/getting-started.tsx +625 -0
  73. package/template/components/key-metrics.tsx +920 -0
  74. package/template/components/leo-insight-indicator.tsx +364 -0
  75. package/template/components/leo-typing-dots.tsx +121 -0
  76. package/template/components/list-hub-status-badge.tsx +51 -0
  77. package/template/components/list-page-dashboard-charts.tsx +18 -0
  78. package/template/components/nav-documents.tsx +89 -0
  79. package/template/components/nav-main.tsx +58 -0
  80. package/template/components/nav-secondary.tsx +64 -0
  81. package/template/components/nav-user.tsx +190 -0
  82. package/template/components/new-placement-back-btn.tsx +28 -0
  83. package/template/components/new-placement-form.tsx +1066 -0
  84. package/template/components/onboarding/index.ts +4 -0
  85. package/template/components/onboarding/onboarding-01.tsx +7 -0
  86. package/template/components/onboarding/onboarding-02.tsx +7 -0
  87. package/template/components/onboarding/onboarding-03.tsx +7 -0
  88. package/template/components/onboarding/onboarding-04.tsx +7 -0
  89. package/template/components/page-header.tsx +57 -0
  90. package/template/components/placement-detail.tsx +438 -0
  91. package/template/components/placements-board-view.tsx +404 -0
  92. package/template/components/placements-list-view.tsx +285 -0
  93. package/template/components/placements-page-header.tsx +160 -0
  94. package/template/components/placements-table-columns.tsx +639 -0
  95. package/template/components/product-switcher.tsx +116 -0
  96. package/template/components/question-bank-board-view.tsx +205 -0
  97. package/template/components/question-bank-client.tsx +77 -0
  98. package/template/components/question-bank-list-view.tsx +59 -0
  99. package/template/components/question-bank-page-header.tsx +89 -0
  100. package/template/components/question-bank-table.tsx +586 -0
  101. package/template/components/rotations-empty-state.tsx +47 -0
  102. package/template/components/rotations-panel-activator.tsx +8 -0
  103. package/template/components/secondary-nav.tsx +394 -0
  104. package/template/components/secondary-panel.tsx +239 -0
  105. package/template/components/section-cards.tsx +106 -0
  106. package/template/components/settings-appearance-card.tsx +424 -0
  107. package/template/components/settings-client.tsx +537 -0
  108. package/template/components/settings-form-row.tsx +42 -0
  109. package/template/components/sidebar-auto-collapse.tsx +23 -0
  110. package/template/components/sidebar-auto-open.tsx +18 -0
  111. package/template/components/sidebar-shell.tsx +37 -0
  112. package/template/components/site-header.tsx +93 -0
  113. package/template/components/sites-all-client.tsx +154 -0
  114. package/template/components/sites-board-view.tsx +67 -0
  115. package/template/components/sites-list-view.tsx +47 -0
  116. package/template/components/sites-table.tsx +312 -0
  117. package/template/components/system-banner-slot.tsx +66 -0
  118. package/template/components/table-properties/column-row.tsx +90 -0
  119. package/template/components/table-properties/draggable-list.ts +49 -0
  120. package/template/components/table-properties/drawer-button.tsx +231 -0
  121. package/template/components/table-properties/drawer.tsx +1102 -0
  122. package/template/components/table-properties/filter-card.tsx +251 -0
  123. package/template/components/table-properties/index.ts +22 -0
  124. package/template/components/table-properties/sort-card.tsx +59 -0
  125. package/template/components/table-properties/types.ts +124 -0
  126. package/template/components/task-list-panel.tsx +98 -0
  127. package/template/components/task-priority-badge.tsx +28 -0
  128. package/template/components/team-board-view.tsx +114 -0
  129. package/template/components/team-client.tsx +93 -0
  130. package/template/components/team-list-view.tsx +62 -0
  131. package/template/components/team-page-header.tsx +92 -0
  132. package/template/components/team-table.tsx +525 -0
  133. package/template/components/templates/list-page.tsx +576 -0
  134. package/template/components/templates/primary-page-template.tsx +56 -0
  135. package/template/components/theme-color-sync.tsx +32 -0
  136. package/template/components/theme-provider.tsx +71 -0
  137. package/template/components/tinted-icon-disc.tsx +53 -0
  138. package/template/components/ui/ai-thinking-surface.tsx +121 -0
  139. package/template/components/ui/avatar.tsx +1 -0
  140. package/template/components/ui/badge.tsx +1 -0
  141. package/template/components/ui/banner.tsx +1 -0
  142. package/template/components/ui/breadcrumb.tsx +1 -0
  143. package/template/components/ui/button.tsx +1 -0
  144. package/template/components/ui/calendar.tsx +1 -0
  145. package/template/components/ui/card.tsx +1 -0
  146. package/template/components/ui/chart.tsx +1 -0
  147. package/template/components/ui/checkbox.tsx +1 -0
  148. package/template/components/ui/coach-mark.tsx +1 -0
  149. package/template/components/ui/collapsible.tsx +1 -0
  150. package/template/components/ui/command.tsx +1 -0
  151. package/template/components/ui/date-picker-field.tsx +1 -0
  152. package/template/components/ui/dialog.tsx +1 -0
  153. package/template/components/ui/dot-pattern.tsx +159 -0
  154. package/template/components/ui/drag-handle-grip.tsx +1 -0
  155. package/template/components/ui/drawer.tsx +1 -0
  156. package/template/components/ui/dropdown-menu.tsx +1 -0
  157. package/template/components/ui/field.tsx +1 -0
  158. package/template/components/ui/form.tsx +1 -0
  159. package/template/components/ui/input-group.tsx +1 -0
  160. package/template/components/ui/input-mask.tsx +1 -0
  161. package/template/components/ui/input.tsx +1 -0
  162. package/template/components/ui/kbd.tsx +1 -0
  163. package/template/components/ui/label.tsx +1 -0
  164. package/template/components/ui/leo-icon.tsx +726 -0
  165. package/template/components/ui/payment-card-fields.tsx +1 -0
  166. package/template/components/ui/popover.tsx +1 -0
  167. package/template/components/ui/radio-group.tsx +1 -0
  168. package/template/components/ui/select.tsx +1 -0
  169. package/template/components/ui/selection-tile-grid.tsx +1 -0
  170. package/template/components/ui/separator.tsx +1 -0
  171. package/template/components/ui/sheet.tsx +1 -0
  172. package/template/components/ui/sidebar.tsx +1 -0
  173. package/template/components/ui/skeleton.tsx +1 -0
  174. package/template/components/ui/sonner.tsx +1 -0
  175. package/template/components/ui/status-badge.tsx +1 -0
  176. package/template/components/ui/table.tsx +1 -0
  177. package/template/components/ui/tabs.tsx +1 -0
  178. package/template/components/ui/textarea.tsx +1 -0
  179. package/template/components/ui/tip.tsx +1 -0
  180. package/template/components/ui/toggle-group.tsx +1 -0
  181. package/template/components/ui/toggle-switch.tsx +1 -0
  182. package/template/components/ui/toggle.tsx +1 -0
  183. package/template/components/ui/tooltip.tsx +1 -0
  184. package/template/components/ui/view-segmented-control.tsx +1 -0
  185. package/template/components.json +27 -0
  186. package/template/contexts/chart-variant-context.tsx +35 -0
  187. package/template/contexts/command-menu-context.tsx +28 -0
  188. package/template/contexts/dashboard-view-context.tsx +35 -0
  189. package/template/contexts/product-context.tsx +38 -0
  190. package/template/contexts/system-banner-context.tsx +127 -0
  191. package/template/docs/command-menu-pattern.md +45 -0
  192. package/template/docs/data-views-pattern.md +160 -0
  193. package/template/ecosystem.config.cjs +20 -0
  194. package/template/eslint.config.mjs +18 -0
  195. package/template/fontawesome-subset.manifest.json +190 -0
  196. package/template/hooks/.gitkeep +0 -0
  197. package/template/hooks/use-app-theme.ts +1 -0
  198. package/template/hooks/use-coach-mark.ts +1 -0
  199. package/template/hooks/use-mobile.ts +1 -0
  200. package/template/hooks/use-mod-key-label.ts +1 -0
  201. package/template/lib/.gitkeep +0 -0
  202. package/template/lib/ask-leo-route-context.ts +133 -0
  203. package/template/lib/chart-keyboard-selection.test.ts +20 -0
  204. package/template/lib/chart-keyboard-selection.ts +17 -0
  205. package/template/lib/chart-line-dash.ts +16 -0
  206. package/template/lib/coach-mark-registry.ts +68 -0
  207. package/template/lib/command-menu-config.ts +127 -0
  208. package/template/lib/command-menu-search-data.ts +44 -0
  209. package/template/lib/conditional-rule-match.ts +32 -0
  210. package/template/lib/dashboard-customize-coach-mark.ts +18 -0
  211. package/template/lib/dashboard-layout-merge.ts +63 -0
  212. package/template/lib/data-list-display-options.ts +35 -0
  213. package/template/lib/data-list-persistence.ts +280 -0
  214. package/template/lib/data-list-view-surface.ts +58 -0
  215. package/template/lib/data-list-view.ts +29 -0
  216. package/template/lib/data-view-dashboard-storage.ts +101 -0
  217. package/template/lib/date-filter.ts +8 -0
  218. package/template/lib/dev-log.test.ts +28 -0
  219. package/template/lib/dev-log.ts +8 -0
  220. package/template/lib/editable-target.ts +10 -0
  221. package/template/lib/floating-sheet-panel.ts +72 -0
  222. package/template/lib/initials-from-name.ts +7 -0
  223. package/template/lib/list-page-table-properties.ts +52 -0
  224. package/template/lib/list-status-badges.ts +168 -0
  225. package/template/lib/logo-dev.ts +12 -0
  226. package/template/lib/mock/compliance-kpi.ts +61 -0
  227. package/template/lib/mock/compliance.ts +146 -0
  228. package/template/lib/mock/dashboard.ts +105 -0
  229. package/template/lib/mock/navigation.tsx +231 -0
  230. package/template/lib/mock/placements-kpi.ts +134 -0
  231. package/template/lib/mock/placements.ts +183 -0
  232. package/template/lib/mock/question-bank-kpi.ts +61 -0
  233. package/template/lib/mock/question-bank.ts +142 -0
  234. package/template/lib/mock/sites-directory.ts +16 -0
  235. package/template/lib/mock/sites-kpi.ts +25 -0
  236. package/template/lib/mock/team-kpi.ts +60 -0
  237. package/template/lib/mock/team.ts +118 -0
  238. package/template/lib/motion-ui.ts +17 -0
  239. package/template/lib/placement-board-card-layout.ts +79 -0
  240. package/template/lib/placement-lifecycle.ts +5 -0
  241. package/template/lib/row-height.ts +10 -0
  242. package/template/lib/stock-portrait.ts +11 -0
  243. package/template/lib/utils.test.ts +13 -0
  244. package/template/lib/utils.ts +1 -0
  245. package/template/next.config.mjs +15 -0
  246. package/template/package.json +83 -0
  247. package/template/postcss.config.mjs +8 -0
  248. package/template/public/.gitkeep +0 -0
  249. package/template/public/Illustration/Rotation.svg +74 -0
  250. package/template/public/avatars/user.svg +11 -0
  251. package/template/public/favicon/favicon.ico +0 -0
  252. package/template/public/favicon.ico +0 -0
  253. package/template/public/logos/exxat-one.svg +36 -0
  254. package/template/public/logos/exxat-prism.svg +39 -0
  255. package/template/public/mock-schools/emory.svg +4 -0
  256. package/template/public/mock-schools/rush.svg +4 -0
  257. package/template/scripts/fontawesome-subset-audit.mjs +190 -0
  258. package/template/scripts/pm2-startup-macos.sh +13 -0
  259. package/template/skills-lock.json +10 -0
  260. package/template/stores/app-store.ts +33 -0
  261. package/template/tests/setup.ts +1 -0
  262. package/template/tsconfig.json +35 -0
  263. package/template/types/react-payment-inputs.d.ts +19 -0
  264. 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
+ }