@exxatdesignux/ui 0.2.18 → 0.2.19

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 (140) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +21 -6
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +4 -2
  9. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  10. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  11. package/consumer-extras/patterns/data-views-pattern.md +40 -3
  12. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  13. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +5 -3
  14. package/package.json +2 -1
  15. package/src/components/ui/button-group.tsx +81 -0
  16. package/src/components/ui/button.tsx +4 -4
  17. package/src/globals.css +7 -1858
  18. package/src/theme.css +10 -1126
  19. package/src/tokens/README.md +15 -0
  20. package/src/tokens/base.css +337 -0
  21. package/src/tokens/high-contrast.css +1195 -0
  22. package/src/tokens/layers.css +224 -0
  23. package/src/tokens/tailwind-bridge.css +118 -0
  24. package/src/tokens/themes.css +201 -0
  25. package/template/AGENTS.md +60 -22
  26. package/template/app/(app)/dashboard/loading.tsx +3 -15
  27. package/template/app/(app)/dashboard/page.tsx +2 -14
  28. package/template/app/(app)/data-list/layout.tsx +43 -0
  29. package/template/app/(app)/data-list/page.tsx +2 -2
  30. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  31. package/template/app/(app)/examples/page.tsx +1 -0
  32. package/template/app/(app)/loading.tsx +1 -18
  33. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  34. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  35. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  36. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  37. package/template/app/(app)/question-bank/page.tsx +2 -1
  38. package/template/app/(app)/settings/page.tsx +4 -5
  39. package/template/app/globals.css +7 -1964
  40. package/template/components/app-route-loading.tsx +14 -0
  41. package/template/components/app-sidebar.tsx +70 -55
  42. package/template/components/data-views/index.ts +37 -9
  43. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  44. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  45. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  46. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  47. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  48. package/template/components/list-hub-board-view.tsx +68 -0
  49. package/template/components/list-hub-client.tsx +186 -0
  50. package/template/components/list-hub-list-view.tsx +36 -0
  51. package/template/components/list-hub-panel-activator.tsx +8 -0
  52. package/template/components/list-hub-secondary-nav.tsx +121 -0
  53. package/template/components/list-hub-table.tsx +336 -0
  54. package/template/components/new-question-composer.tsx +6 -24
  55. package/template/components/product-switcher.tsx +3 -2
  56. package/template/components/question-bank-client.tsx +4 -1
  57. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  58. package/template/components/question-bank-table.tsx +143 -485
  59. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  60. package/template/components/secondary-panel.tsx +4 -44
  61. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  62. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  63. package/template/components/secondary-panels/registry.tsx +15 -0
  64. package/template/components/settings-appearance-card.tsx +3 -2
  65. package/template/components/settings-client.tsx +59 -15
  66. package/template/components/settings-form-row.tsx +9 -4
  67. package/template/components/table-properties/drawer-button.tsx +13 -0
  68. package/template/components/table-properties/drawer.tsx +65 -4
  69. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  70. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  71. package/template/components/templates/list-page.tsx +29 -5
  72. package/template/components/templates/nested-secondary-panel-shell.tsx +2 -1
  73. package/template/components/templates/page-loading-shell.tsx +262 -0
  74. package/template/components/ui/button-group.tsx +1 -0
  75. package/template/docs/consumer-app-pattern.md +39 -0
  76. package/template/docs/data-views-pattern.md +40 -3
  77. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  78. package/template/docs/focused-workflow-page-pattern.md +84 -0
  79. package/template/docs/shell-surface-elevation-pattern.md +5 -3
  80. package/template/lib/command-menu-search-data.ts +11 -27
  81. package/template/lib/data-list-display-options.ts +16 -2
  82. package/template/lib/data-list-view-registry.ts +104 -0
  83. package/template/lib/data-list-view-surface.ts +15 -1
  84. package/template/lib/data-list-view.ts +10 -1
  85. package/template/lib/data-view-dashboard-storage.ts +38 -35
  86. package/template/lib/hub-connected-view-renderers.ts +58 -0
  87. package/template/lib/list-hub-nav.ts +121 -0
  88. package/template/lib/list-hub-supported-views.ts +10 -0
  89. package/template/lib/list-page-table-properties.ts +3 -7
  90. package/template/lib/list-status-badges.ts +4 -97
  91. package/template/lib/mock/list-hub-directory.ts +27 -0
  92. package/template/lib/mock/list-hub-kpi.ts +27 -0
  93. package/template/lib/mock/navigation.tsx +1 -0
  94. package/template/lib/page-loading-variant.ts +40 -0
  95. package/template/lib/question-bank-supported-views.ts +13 -0
  96. package/template/lib/table-state-lifecycle.ts +2 -2
  97. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  98. package/template/app/(app)/data-list/new/page.tsx +0 -34
  99. package/template/components/compliance-board-view.tsx +0 -142
  100. package/template/components/compliance-client.tsx +0 -92
  101. package/template/components/compliance-list-view.tsx +0 -54
  102. package/template/components/compliance-page-header.tsx +0 -89
  103. package/template/components/compliance-table.tsx +0 -612
  104. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  105. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  106. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  107. package/template/components/new-placement-back-btn.tsx +0 -28
  108. package/template/components/new-placement-form.tsx +0 -1068
  109. package/template/components/placement-board-card.tsx +0 -262
  110. package/template/components/placement-detail.tsx +0 -438
  111. package/template/components/placements-board-view.tsx +0 -404
  112. package/template/components/placements-client.tsx +0 -252
  113. package/template/components/placements-list-view.tsx +0 -171
  114. package/template/components/placements-page-header.tsx +0 -166
  115. package/template/components/placements-table-cells.test.tsx +0 -22
  116. package/template/components/placements-table-cells.tsx +0 -173
  117. package/template/components/placements-table-columns.tsx +0 -640
  118. package/template/components/placements-table.tsx +0 -1642
  119. package/template/components/rotations-empty-state.tsx +0 -50
  120. package/template/components/rotations-panel-activator.tsx +0 -8
  121. package/template/components/sites-all-client.tsx +0 -154
  122. package/template/components/sites-board-view.tsx +0 -67
  123. package/template/components/sites-list-view.tsx +0 -42
  124. package/template/components/sites-table.tsx +0 -382
  125. package/template/components/team-board-view.tsx +0 -122
  126. package/template/components/team-client.tsx +0 -100
  127. package/template/components/team-list-view.tsx +0 -59
  128. package/template/components/team-page-header.tsx +0 -92
  129. package/template/components/team-table.tsx +0 -693
  130. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  131. package/template/lib/mock/compliance-kpi.ts +0 -61
  132. package/template/lib/mock/compliance.ts +0 -146
  133. package/template/lib/mock/placements-kpi.ts +0 -134
  134. package/template/lib/mock/placements.ts +0 -183
  135. package/template/lib/mock/sites-directory.ts +0 -16
  136. package/template/lib/mock/sites-kpi.ts +0 -25
  137. package/template/lib/mock/team-kpi.ts +0 -60
  138. package/template/lib/mock/team.ts +0 -118
  139. package/template/lib/placement-board-card-layout.ts +0 -79
  140. package/template/lib/placement-lifecycle.ts +0 -5
@@ -0,0 +1,593 @@
1
+ "use client"
2
+
3
+ /**
4
+ * ListPageCalendarView — reusable calendar for list hubs.
5
+ * Right panel: sticky toolbar (Month/Week, Today, prev/next) + month title + weekday header,
6
+ * then one continuous vertically scrollable week strip (Sun–Sat columns), not stacked month cards.
7
+ */
8
+
9
+ import * as React from "react"
10
+ import { Calendar } from "@/components/ui/calendar"
11
+ import { Button } from "@/components/ui/button"
12
+ import { ButtonGroup } from "@/components/ui/button-group"
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuItem,
17
+ DropdownMenuTrigger,
18
+ } from "@/components/ui/dropdown-menu"
19
+ import { cn } from "@/lib/utils"
20
+ import { formatDateUS, ymdToLocalDate, localDateToYmd } from "@/lib/date-filter"
21
+ import {
22
+ CALENDAR_MAIN_VIEW_TILES,
23
+ type CalendarMainView,
24
+ } from "@/lib/data-list-display-options"
25
+ import {
26
+ LIST_PAGE_CALENDAR_HEIGHT_STYLE,
27
+ } from "@/components/data-views/list-page-split-hub-chrome"
28
+ import { ListPageViewFrame, LIST_PAGE_VIEW_FRAME_MAX_WIDE } from "@/components/data-views/list-page-view-frame"
29
+
30
+ const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
31
+ const MONTHS_SCROLL_PAST = 2
32
+ const MONTHS_SCROLL_FUTURE = 14
33
+ const WEEK_ROW_HEIGHT_PX = 92
34
+ const MAX_CHIPS_PER_DAY = 2
35
+
36
+ /** Seven-day week grid — `bg-dt-header-bg` on head row; HC via `data-slot`. */
37
+ const CALENDAR_GRID_CLASS = "grid grid-cols-7"
38
+
39
+ const calendarHeadCellClass =
40
+ "flex items-center justify-center border-r border-border bg-dt-header-bg py-2 text-xs font-medium text-muted-foreground last:border-r-0"
41
+
42
+ const calendarDayCellClass =
43
+ "relative flex min-w-0 flex-col border-r border-border bg-card last:border-r-0"
44
+
45
+ function eventYmd(rowDate: Date | string): string | null {
46
+ if (rowDate instanceof Date) {
47
+ if (Number.isNaN(rowDate.getTime())) return null
48
+ return localDateToYmd(rowDate)
49
+ }
50
+ const s = String(rowDate).trim()
51
+ if (!s) return null
52
+ const iso = s.slice(0, 10)
53
+ return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null
54
+ }
55
+
56
+ function startOfMonth(d: Date) {
57
+ return new Date(d.getFullYear(), d.getMonth(), 1)
58
+ }
59
+
60
+ function addMonths(d: Date, n: number) {
61
+ return new Date(d.getFullYear(), d.getMonth() + n, 1)
62
+ }
63
+
64
+ function startOfWeekSunday(d: Date) {
65
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate())
66
+ x.setDate(x.getDate() - x.getDay())
67
+ return x
68
+ }
69
+
70
+ function addDays(d: Date, n: number) {
71
+ const x = new Date(d.getFullYear(), d.getMonth(), d.getDate())
72
+ x.setDate(x.getDate() + n)
73
+ return x
74
+ }
75
+
76
+ function buildScrollWeeks(anchor: Date) {
77
+ const rangeStart = addMonths(startOfMonth(anchor), -MONTHS_SCROLL_PAST)
78
+ const rangeEnd = addMonths(startOfMonth(anchor), MONTHS_SCROLL_FUTURE + 1)
79
+ rangeEnd.setDate(0)
80
+
81
+ let cursor = startOfWeekSunday(rangeStart)
82
+ const end = startOfWeekSunday(addDays(rangeEnd, 6))
83
+ const weeks: Date[][] = []
84
+
85
+ while (cursor <= end) {
86
+ const week: Date[] = []
87
+ for (let i = 0; i < 7; i++) {
88
+ week.push(addDays(cursor, i))
89
+ }
90
+ weeks.push(week)
91
+ cursor = addDays(cursor, 7)
92
+ }
93
+
94
+ return weeks
95
+ }
96
+
97
+ function monthLabelParts(d: Date) {
98
+ return {
99
+ month: d.toLocaleString("default", { month: "long" }),
100
+ year: d.getFullYear(),
101
+ }
102
+ }
103
+
104
+ function isFirstDayOfMonth(day: Date) {
105
+ return day.getDate() === 1
106
+ }
107
+
108
+ export type ListPageCalendarViewProps<T> = {
109
+ rows: T[]
110
+ getRowId: (row: T) => string | number
111
+ getEventDate: (row: T) => Date | string | null | undefined
112
+ getEventLabel: (row: T) => string
113
+ getEventMeta?: (row: T) => string | undefined
114
+ onEventActivate?: (row: T) => void
115
+ emptyMonthLabel?: string
116
+ ariaLabel?: string
117
+ showSummaryPanel: boolean
118
+ calendarMainView: CalendarMainView
119
+ onCalendarMainViewChange?: (view: CalendarMainView) => void
120
+ }
121
+
122
+ export function ListPageCalendarView<T>({
123
+ rows,
124
+ getRowId,
125
+ getEventDate,
126
+ getEventLabel,
127
+ getEventMeta,
128
+ onEventActivate,
129
+ emptyMonthLabel = "No events on this day",
130
+ ariaLabel = "Schedule calendar",
131
+ showSummaryPanel,
132
+ calendarMainView,
133
+ onCalendarMainViewChange,
134
+ }: ListPageCalendarViewProps<T>) {
135
+ const scrollWeeks = React.useMemo(() => buildScrollWeeks(new Date()), [])
136
+ const [visibleMonth, setVisibleMonth] = React.useState(() => startOfMonth(new Date()))
137
+ const [selectedYmd, setSelectedYmd] = React.useState<string | null>(() =>
138
+ localDateToYmd(new Date()),
139
+ )
140
+ const scrollRef = React.useRef<HTMLDivElement>(null)
141
+ const weekRowRefs = React.useRef<Map<string, HTMLElement>>(new Map())
142
+ const scrollRafRef = React.useRef<number | null>(null)
143
+
144
+ const eventsByYmd = React.useMemo(() => {
145
+ const map = new Map<string, T[]>()
146
+ for (const row of rows) {
147
+ const raw = getEventDate(row)
148
+ if (raw == null) continue
149
+ const ymd = eventYmd(raw)
150
+ if (!ymd) continue
151
+ const list = map.get(ymd) ?? []
152
+ list.push(row)
153
+ map.set(ymd, list)
154
+ }
155
+ return map
156
+ }, [rows, getEventDate])
157
+
158
+ const visibleMonthEvents = React.useMemo(() => {
159
+ const y = visibleMonth.getFullYear()
160
+ const m = visibleMonth.getMonth()
161
+ const items: { ymd: string; row: T }[] = []
162
+ for (const row of rows) {
163
+ const raw = getEventDate(row)
164
+ if (raw == null) continue
165
+ const ymd = eventYmd(raw)
166
+ if (!ymd) continue
167
+ const d = ymdToLocalDate(ymd)
168
+ if (!d) continue
169
+ if (d.getFullYear() === y && d.getMonth() === m) {
170
+ items.push({ ymd, row })
171
+ }
172
+ }
173
+ items.sort((a, b) => a.ymd.localeCompare(b.ymd))
174
+ return items
175
+ }, [rows, visibleMonth, getEventDate])
176
+
177
+ const { month: visibleMonthName, year: visibleYear } = monthLabelParts(visibleMonth)
178
+
179
+ const mainViewLabel =
180
+ CALENDAR_MAIN_VIEW_TILES.find(t => t.value === calendarMainView)?.label ?? "Month"
181
+
182
+ const weekRowKey = (weekStart: Date) => localDateToYmd(weekStart)
183
+
184
+ const scrollToWeekStart = React.useCallback(
185
+ (weekStart: Date, behavior: ScrollBehavior = "smooth") => {
186
+ const key = weekRowKey(weekStart)
187
+ const el = weekRowRefs.current.get(key)
188
+ const root = scrollRef.current
189
+ if (el && root) {
190
+ root.scrollTo({ top: el.offsetTop, behavior })
191
+ }
192
+ },
193
+ [],
194
+ )
195
+
196
+ const scrollToMonth = React.useCallback(
197
+ (month: Date, behavior: ScrollBehavior = "smooth") => {
198
+ const target = startOfMonth(month)
199
+ setVisibleMonth(target)
200
+ scrollToWeekStart(startOfWeekSunday(target), behavior)
201
+ },
202
+ [scrollToWeekStart],
203
+ )
204
+
205
+ const goPrevMonth = React.useCallback(() => {
206
+ scrollToMonth(addMonths(visibleMonth, -1))
207
+ }, [scrollToMonth, visibleMonth])
208
+
209
+ const goNextMonth = React.useCallback(() => {
210
+ scrollToMonth(addMonths(visibleMonth, 1))
211
+ }, [scrollToMonth, visibleMonth])
212
+
213
+ const goToday = React.useCallback(() => {
214
+ const today = new Date()
215
+ setSelectedYmd(localDateToYmd(today))
216
+ scrollToMonth(today)
217
+ }, [scrollToMonth])
218
+
219
+ React.useEffect(() => {
220
+ const root = scrollRef.current
221
+ if (!root) return
222
+
223
+ const syncVisibleMonthFromScroll = () => {
224
+ const rootTop = root.scrollTop
225
+ const anchorY = rootTop + 48
226
+
227
+ let bestKey: string | null = null
228
+ let bestDistance = Number.POSITIVE_INFINITY
229
+
230
+ for (const [key, el] of weekRowRefs.current.entries()) {
231
+ const distance = Math.abs(el.offsetTop - anchorY)
232
+ if (distance < bestDistance) {
233
+ bestDistance = distance
234
+ bestKey = key
235
+ }
236
+ }
237
+
238
+ if (!bestKey) return
239
+ const d = ymdToLocalDate(bestKey)
240
+ if (!d) return
241
+ const next = startOfMonth(d)
242
+ setVisibleMonth(prev =>
243
+ prev.getFullYear() === next.getFullYear() && prev.getMonth() === next.getMonth()
244
+ ? prev
245
+ : next,
246
+ )
247
+ }
248
+
249
+ const onScroll = () => {
250
+ if (scrollRafRef.current != null) cancelAnimationFrame(scrollRafRef.current)
251
+ scrollRafRef.current = requestAnimationFrame(() => {
252
+ scrollRafRef.current = null
253
+ syncVisibleMonthFromScroll()
254
+ })
255
+ }
256
+
257
+ root.addEventListener("scroll", onScroll, { passive: true })
258
+ return () => {
259
+ root.removeEventListener("scroll", onScroll)
260
+ if (scrollRafRef.current != null) cancelAnimationFrame(scrollRafRef.current)
261
+ }
262
+ }, [scrollWeeks, calendarMainView, showSummaryPanel])
263
+
264
+ const didInitialScrollRef = React.useRef(false)
265
+ React.useEffect(() => {
266
+ if (didInitialScrollRef.current) return
267
+ const key = weekRowKey(startOfWeekSunday(new Date()))
268
+ if (!weekRowRefs.current.has(key)) return
269
+ didInitialScrollRef.current = true
270
+ const t = window.setTimeout(() => scrollToMonth(new Date(), "auto"), 0)
271
+ return () => window.clearTimeout(t)
272
+ // eslint-disable-next-line react-hooks/exhaustive-deps
273
+ }, [scrollWeeks.length, showSummaryPanel])
274
+
275
+ const weeksToRender = React.useMemo(() => {
276
+ if (calendarMainView === "month") return scrollWeeks
277
+ const anchor = selectedYmd ? ymdToLocalDate(selectedYmd) : new Date()
278
+ const day = anchor ?? new Date()
279
+ return [scrollWeeks.find(w => w.some(d => localDateToYmd(d) === localDateToYmd(day))) ?? scrollWeeks[0]]
280
+ }, [calendarMainView, scrollWeeks, selectedYmd])
281
+
282
+ const monthLabelFull = `${visibleMonthName} ${visibleYear}`
283
+
284
+ return (
285
+ <ListPageViewFrame
286
+ maxWidthClassName={LIST_PAGE_VIEW_FRAME_MAX_WIDE}
287
+ gutterClassName="mx-4 mb-4 flex min-h-0 flex-1 flex-col lg:mx-6"
288
+ className="min-h-0"
289
+ aria-label={ariaLabel}
290
+ >
291
+ <div
292
+ data-slot="list-page-calendar"
293
+ className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card shadow-xs"
294
+ style={LIST_PAGE_CALENDAR_HEIGHT_STYLE}
295
+ >
296
+ {showSummaryPanel ? (
297
+ <aside
298
+ className="flex w-72 shrink-0 flex-col border-r border-border bg-muted/20"
299
+ aria-label="Calendar summary"
300
+ >
301
+ <div className="border-b border-border p-3">
302
+ <Calendar
303
+ mode="single"
304
+ month={visibleMonth}
305
+ onMonthChange={m => scrollToMonth(m)}
306
+ selected={selectedYmd ? ymdToLocalDate(selectedYmd) : undefined}
307
+ onSelect={d => {
308
+ const ymd = d ? localDateToYmd(d) : null
309
+ setSelectedYmd(ymd)
310
+ if (d) scrollToMonth(d)
311
+ }}
312
+ className="mx-auto bg-transparent p-0"
313
+ />
314
+ </div>
315
+
316
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden p-3">
317
+ <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
318
+ Events · {monthLabelFull}
319
+ </h3>
320
+
321
+ <ul className="min-h-0 flex-1 space-y-1 overflow-y-auto" aria-label="Events this month">
322
+ {visibleMonthEvents.length === 0 ? (
323
+ <li className="text-xs text-muted-foreground">{emptyMonthLabel}</li>
324
+ ) : (
325
+ visibleMonthEvents.map(({ ymd, row }) => {
326
+ const id = getRowId(row)
327
+ const label = getEventLabel(row)
328
+ const meta = getEventMeta?.(row)
329
+ const isSelected = selectedYmd === ymd
330
+ return (
331
+ <li key={`${String(id)}-${ymd}`}>
332
+ <button
333
+ type="button"
334
+ onClick={() => {
335
+ setSelectedYmd(ymd)
336
+ onEventActivate?.(row)
337
+ }}
338
+ className={cn(
339
+ "flex w-full flex-col gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
340
+ isSelected
341
+ ? "border-brand/40 bg-brand/5"
342
+ : "border-transparent hover:bg-muted/60",
343
+ )}
344
+ >
345
+ <span className="font-medium text-foreground">{label}</span>
346
+ <span className="text-xs text-muted-foreground tabular-nums">
347
+ {formatDateUS(ymd)}
348
+ {meta ? ` · ${meta}` : ""}
349
+ </span>
350
+ </button>
351
+ </li>
352
+ )
353
+ })
354
+ )}
355
+ </ul>
356
+ </div>
357
+ </aside>
358
+ ) : null}
359
+
360
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
361
+ <div
362
+ data-slot="list-page-calendar-chrome"
363
+ className="sticky top-0 z-20 shrink-0 border-b border-border bg-card"
364
+ >
365
+ <div
366
+ data-slot="list-page-calendar-toolbar"
367
+ className="relative px-4 pb-2 pt-4 lg:px-6 lg:pb-2.5 lg:pt-6"
368
+ >
369
+ <p className="min-w-0 pe-[min(100%,14rem)] text-lg text-foreground sm:pe-72">
370
+ <strong className="font-semibold">{visibleMonthName}</strong>
371
+ <span className="font-normal text-muted-foreground"> {visibleYear}</span>
372
+ </p>
373
+
374
+ <div className="absolute end-4 top-4 flex flex-wrap items-center justify-end gap-2 lg:end-6 lg:top-6">
375
+ {onCalendarMainViewChange ? (
376
+ <DropdownMenu>
377
+ <DropdownMenuTrigger asChild>
378
+ <Button type="button" variant="outline" size="sm" className="gap-1.5">
379
+ <span>{mainViewLabel}</span>
380
+ <i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
381
+ </Button>
382
+ </DropdownMenuTrigger>
383
+ <DropdownMenuContent align="end">
384
+ {CALENDAR_MAIN_VIEW_TILES.map(tile => (
385
+ <DropdownMenuItem
386
+ key={tile.value}
387
+ onSelect={() => onCalendarMainViewChange(tile.value)}
388
+ >
389
+ <i className={cn("fa-light", tile.icon, "mr-2 w-4")} aria-hidden="true" />
390
+ {tile.label}
391
+ </DropdownMenuItem>
392
+ ))}
393
+ </DropdownMenuContent>
394
+ </DropdownMenu>
395
+ ) : (
396
+ <span className="text-sm font-medium text-muted-foreground">{mainViewLabel}</span>
397
+ )}
398
+
399
+ <ButtonGroup aria-label="Calendar navigation">
400
+ <Button type="button" variant="outline" size="sm" onClick={goToday}>
401
+ Today
402
+ </Button>
403
+ <Button
404
+ type="button"
405
+ variant="outline"
406
+ size="icon-sm"
407
+ aria-label="Previous month"
408
+ onClick={goPrevMonth}
409
+ >
410
+ <i className="fa-light fa-chevron-left text-sm" aria-hidden="true" />
411
+ </Button>
412
+ <Button
413
+ type="button"
414
+ variant="outline"
415
+ size="icon-sm"
416
+ aria-label="Next month"
417
+ onClick={goNextMonth}
418
+ >
419
+ <i className="fa-light fa-chevron-right text-sm" aria-hidden="true" />
420
+ </Button>
421
+ </ButtonGroup>
422
+ </div>
423
+ </div>
424
+
425
+ <div
426
+ data-slot="list-page-calendar-head"
427
+ className={cn(CALENDAR_GRID_CLASS, "border border-border border-t-0")}
428
+ role="row"
429
+ >
430
+ {WEEKDAY_LABELS.map(label => (
431
+ <div
432
+ key={label}
433
+ data-slot="list-page-calendar-head-cell"
434
+ role="columnheader"
435
+ className={calendarHeadCellClass}
436
+ >
437
+ {label}
438
+ </div>
439
+ ))}
440
+ </div>
441
+ </div>
442
+
443
+ <div
444
+ ref={scrollRef}
445
+ data-slot="list-page-calendar-body"
446
+ className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain"
447
+ aria-label="Calendar — scroll vertically to move between weeks"
448
+ >
449
+ <div
450
+ className="relative"
451
+ style={{
452
+ minHeight:
453
+ calendarMainView === "month"
454
+ ? scrollWeeks.length * WEEK_ROW_HEIGHT_PX
455
+ : WEEK_ROW_HEIGHT_PX,
456
+ }}
457
+ >
458
+ {weeksToRender.map(week => {
459
+ const weekStart = week[0]
460
+ const key = weekRowKey(weekStart)
461
+ return (
462
+ <div
463
+ key={key}
464
+ ref={el => {
465
+ if (el) weekRowRefs.current.set(key, el)
466
+ else weekRowRefs.current.delete(key)
467
+ }}
468
+ data-week-start={key}
469
+ data-slot="list-page-calendar-week-row"
470
+ className="absolute left-0 right-0 border-b border-border"
471
+ style={{
472
+ top:
473
+ calendarMainView === "month"
474
+ ? scrollWeeks.findIndex(w => weekRowKey(w[0]) === key) *
475
+ WEEK_ROW_HEIGHT_PX
476
+ : 0,
477
+ height: WEEK_ROW_HEIGHT_PX,
478
+ }}
479
+ >
480
+ <WeekStripRow
481
+ week={week}
482
+ eventsByYmd={eventsByYmd}
483
+ selectedYmd={selectedYmd}
484
+ getRowId={getRowId}
485
+ getEventLabel={getEventLabel}
486
+ onSelectDay={ymd => setSelectedYmd(ymd)}
487
+ onEventActivate={onEventActivate}
488
+ expanded={calendarMainView === "week"}
489
+ />
490
+ </div>
491
+ )
492
+ })}
493
+ </div>
494
+ </div>
495
+ </div>
496
+ </div>
497
+ </ListPageViewFrame>
498
+ )
499
+ }
500
+
501
+ function WeekStripRow<T>({
502
+ week,
503
+ eventsByYmd,
504
+ selectedYmd,
505
+ getRowId,
506
+ getEventLabel,
507
+ onSelectDay,
508
+ onEventActivate,
509
+ expanded = false,
510
+ }: {
511
+ week: Date[]
512
+ eventsByYmd: Map<string, T[]>
513
+ selectedYmd: string | null
514
+ getRowId: (row: T) => string | number
515
+ getEventLabel: (row: T) => string
516
+ onSelectDay: (ymd: string) => void
517
+ onEventActivate?: (row: T) => void
518
+ expanded?: boolean
519
+ }) {
520
+ const todayYmd = localDateToYmd(new Date())
521
+
522
+ return (
523
+ <div
524
+ className={cn(CALENDAR_GRID_CLASS, "h-full bg-card", expanded && "min-h-[12rem]")}
525
+ role="row"
526
+ >
527
+ {week.map(day => {
528
+ const ymd = localDateToYmd(day)
529
+ const dayEvents = eventsByYmd.get(ymd) ?? []
530
+ const isSelected = selectedYmd === ymd
531
+ const isToday = ymd === todayYmd
532
+ const showMonthName = isFirstDayOfMonth(day)
533
+ const monthName = day.toLocaleString("default", { month: "long" })
534
+
535
+ return (
536
+ <div
537
+ key={ymd}
538
+ data-slot="list-page-calendar-day"
539
+ data-today={isToday ? "true" : undefined}
540
+ data-selected={isSelected ? "true" : undefined}
541
+ className={cn(
542
+ calendarDayCellClass,
543
+ isToday && "bg-brand/5",
544
+ isSelected && "z-[1] ring-2 ring-inset ring-brand",
545
+ )}
546
+ >
547
+ <button
548
+ type="button"
549
+ role="gridcell"
550
+ aria-label={`${formatDateUS(ymd)}${dayEvents.length ? `, ${dayEvents.length} events` : ""}`}
551
+ aria-selected={isSelected}
552
+ onClick={() => onSelectDay(ymd)}
553
+ className="flex w-full items-baseline gap-1 px-1.5 pt-2 text-left text-xs tabular-nums hover:bg-muted/30"
554
+ >
555
+ {showMonthName ? (
556
+ <>
557
+ <strong className="font-semibold text-foreground">{monthName}</strong>
558
+ <span className="text-muted-foreground">{day.getDate()}</span>
559
+ </>
560
+ ) : (
561
+ <span className={cn("text-muted-foreground", isToday && "font-semibold text-brand")}>
562
+ {day.getDate()}
563
+ </span>
564
+ )}
565
+ </button>
566
+
567
+ <div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden px-1 pb-1.5">
568
+ {dayEvents.slice(0, expanded ? 6 : MAX_CHIPS_PER_DAY).map(row => (
569
+ <button
570
+ key={String(getRowId(row))}
571
+ type="button"
572
+ className="w-full truncate rounded-sm bg-brand/10 px-1 py-0.5 text-left text-[11px] font-medium text-foreground hover:bg-brand/15"
573
+ onClick={e => {
574
+ e.stopPropagation()
575
+ onSelectDay(ymd)
576
+ onEventActivate?.(row)
577
+ }}
578
+ >
579
+ {getEventLabel(row)}
580
+ </button>
581
+ ))}
582
+ {!expanded && dayEvents.length > MAX_CHIPS_PER_DAY ? (
583
+ <span className="px-1 text-[11px] text-muted-foreground">
584
+ {dayEvents.length - MAX_CHIPS_PER_DAY} more
585
+ </span>
586
+ ) : null}
587
+ </div>
588
+ </div>
589
+ )
590
+ })}
591
+ </div>
592
+ )
593
+ }
@@ -0,0 +1,66 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Switches list-hub view bodies by `DataListViewRenderKind`.
5
+ * Hubs pass one renderer per kind they support; missing kinds show a clear empty state
6
+ * (never silently fall through to dashboard).
7
+ */
8
+
9
+ import * as React from "react"
10
+ import type { DataListViewType } from "@/lib/data-list-view"
11
+ import {
12
+ dataListViewDefinition,
13
+ dataListViewLabel,
14
+ getDataListViewRenderKind,
15
+ type DataListViewRenderKind,
16
+ } from "@/lib/data-list-view-registry"
17
+
18
+ export type ListPageConnectedViewRenderers = Partial<
19
+ Record<DataListViewRenderKind, React.ReactNode | (() => React.ReactNode)>
20
+ >
21
+
22
+ export interface ListPageConnectedViewBodyProps {
23
+ view: DataListViewType
24
+ /** Human-readable hub name for the not-configured state. */
25
+ hubLabel?: string
26
+ renderers: ListPageConnectedViewRenderers
27
+ }
28
+
29
+ function resolveRenderer(node: React.ReactNode | (() => React.ReactNode) | undefined) {
30
+ if (node == null) return null
31
+ return typeof node === "function" ? node() : node
32
+ }
33
+
34
+ export function ListPageViewNotConfigured({
35
+ view,
36
+ hubLabel = "This hub",
37
+ }: {
38
+ view: DataListViewType
39
+ hubLabel?: string
40
+ }) {
41
+ const label = dataListViewLabel(view)
42
+ return (
43
+ <div
44
+ className="flex flex-1 items-center justify-center px-4 py-12 text-center text-sm text-muted-foreground"
45
+ role="status"
46
+ >
47
+ {hubLabel} does not implement {label}. Add a renderer for{" "}
48
+ <span className="font-medium text-foreground">{dataListViewDefinition(view).renderKind}</span>{" "}
49
+ in this hub&apos;s table component, or remove the view from{" "}
50
+ <code className="rounded bg-muted px-1 py-0.5 text-xs">supportedViewTypes</code>.
51
+ </div>
52
+ )
53
+ }
54
+
55
+ export function ListPageConnectedViewBody({
56
+ view,
57
+ hubLabel,
58
+ renderers,
59
+ }: ListPageConnectedViewBodyProps) {
60
+ const kind = getDataListViewRenderKind(view)
61
+ const body = resolveRenderer(renderers[kind])
62
+ if (body == null) {
63
+ return <ListPageViewNotConfigured view={view} hubLabel={hubLabel} />
64
+ }
65
+ return <>{body}</>
66
+ }