@exxatdesignux/ui 0.2.17 → 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.
- package/CHANGELOG.md +30 -0
- package/consumer-extras/AGENTS.md +76 -0
- package/consumer-extras/README.md +5 -1
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
- package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
- package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
- package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
- package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
- package/consumer-extras/patterns/data-views-pattern.md +42 -3
- package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
- package/package.json +2 -1
- package/src/components/ui/button-group.tsx +81 -0
- package/src/components/ui/button.tsx +4 -4
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +7 -1807
- package/src/theme.css +10 -1126
- package/src/tokens/README.md +15 -0
- package/src/tokens/base.css +337 -0
- package/src/tokens/high-contrast.css +1195 -0
- package/src/tokens/layers.css +224 -0
- package/src/tokens/tailwind-bridge.css +118 -0
- package/src/tokens/themes.css +201 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +66 -21
- package/template/app/(app)/dashboard/loading.tsx +3 -15
- package/template/app/(app)/dashboard/page.tsx +2 -14
- package/template/app/(app)/data-list/layout.tsx +43 -0
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
- package/template/app/(app)/examples/page.tsx +1 -0
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/loading.tsx +1 -18
- package/template/app/(app)/question-bank/find/page.tsx +2 -1
- package/template/app/(app)/question-bank/library/page.tsx +2 -1
- package/template/app/(app)/question-bank/list/page.tsx +2 -1
- package/template/app/(app)/question-bank/new/page.tsx +15 -23
- package/template/app/(app)/question-bank/page.tsx +2 -1
- package/template/app/(app)/settings/page.tsx +4 -5
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +7 -1934
- package/template/app/layout.tsx +2 -0
- package/template/components/app-route-loading.tsx +14 -0
- package/template/components/app-sidebar.tsx +71 -55
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/data-views/index.ts +37 -9
- package/template/components/data-views/list-page-calendar-view.tsx +593 -0
- package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
- package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/examples/focused-workflow-showcase.tsx +183 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/list-hub-board-view.tsx +68 -0
- package/template/components/list-hub-client.tsx +186 -0
- package/template/components/list-hub-list-view.tsx +36 -0
- package/template/components/list-hub-panel-activator.tsx +8 -0
- package/template/components/list-hub-secondary-nav.tsx +121 -0
- package/template/components/list-hub-table.tsx +336 -0
- package/template/components/new-question-composer.tsx +6 -24
- package/template/components/product-switcher.tsx +5 -5
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-client.tsx +4 -1
- package/template/components/question-bank-folder-columns-panel.tsx +104 -0
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +155 -509
- package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
- package/template/components/secondary-panel.tsx +4 -44
- package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
- package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
- package/template/components/secondary-panels/registry.tsx +15 -0
- package/template/components/settings-appearance-card.tsx +3 -2
- package/template/components/settings-client.tsx +59 -15
- package/template/components/settings-form-row.tsx +9 -4
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/table-properties/drawer-button.tsx +51 -20
- package/template/components/table-properties/drawer.tsx +81 -17
- package/template/components/templates/focused-workflow-layouts.tsx +448 -0
- package/template/components/templates/focused-workflow-page-template.tsx +69 -0
- package/template/components/templates/list-page.tsx +40 -13
- package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
- package/template/components/templates/page-loading-shell.tsx +262 -0
- package/template/components/ui/button-group.tsx +1 -0
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/consumer-app-pattern.md +39 -0
- package/template/docs/data-views-pattern.md +42 -3
- package/template/docs/drawer-vs-dialog-pattern.md +3 -1
- package/template/docs/focused-workflow-page-pattern.md +84 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +54 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/command-menu-search-data.ts +11 -27
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-display-options.ts +16 -2
- package/template/lib/data-list-view-registry.ts +104 -0
- package/template/lib/data-list-view-surface.ts +15 -1
- package/template/lib/data-list-view.ts +16 -1
- package/template/lib/data-view-dashboard-storage.ts +38 -35
- package/template/lib/hub-connected-view-renderers.ts +58 -0
- package/template/lib/list-hub-nav.ts +121 -0
- package/template/lib/list-hub-supported-views.ts +10 -0
- package/template/lib/list-page-table-properties.ts +3 -7
- package/template/lib/list-status-badges.ts +4 -97
- package/template/lib/mock/list-hub-directory.ts +27 -0
- package/template/lib/mock/list-hub-kpi.ts +27 -0
- package/template/lib/mock/navigation.tsx +1 -0
- package/template/lib/page-loading-variant.ts +40 -0
- package/template/lib/question-bank-supported-views.ts +13 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +60 -13
- package/template/app/(app)/data-list/[id]/page.tsx +0 -44
- package/template/app/(app)/data-list/new/page.tsx +0 -34
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-list-view.tsx +0 -54
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -632
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -1068
- package/template/components/placement-board-card.tsx +0 -262
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -404
- package/template/components/placements-client.tsx +0 -252
- package/template/components/placements-list-view.tsx +0 -171
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -640
- package/template/components/placements-table.tsx +0 -1675
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-all-client.tsx +0 -154
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-list-view.tsx +0 -42
- package/template/components/sites-table.tsx +0 -402
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-list-view.tsx +0 -59
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -714
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -183
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- 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'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
|
+
}
|