@ttt-productions/ui-core 0.11.7 → 0.11.9
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/dist/react/components/date-picker.d.ts +17 -5
- package/dist/react/components/date-picker.d.ts.map +1 -1
- package/dist/react/components/date-picker.js +278 -64
- package/dist/react/components/date-picker.js.map +1 -1
- package/dist/react/components/scroll-to-top-button.d.ts +15 -3
- package/dist/react/components/scroll-to-top-button.d.ts.map +1 -1
- package/dist/react/components/scroll-to-top-button.js +2 -2
- package/dist/react/components/scroll-to-top-button.js.map +1 -1
- package/package.json +1 -1
- package/src/react/components/date-picker.tsx +404 -176
- package/src/react/components/scroll-to-top-button.tsx +19 -2
|
@@ -25,14 +25,47 @@ const MONTHS_SHORT = [
|
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
|
|
28
|
+
const DAY_NAMES_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
28
29
|
|
|
29
30
|
const YEARS_PER_PAGE = 12
|
|
31
|
+
/** Six rows × seven columns — a calendar month never spans more, and always
|
|
32
|
+
* filling six rows keeps the day grid (and therefore the whole popover
|
|
33
|
+
* footprint) the same height for every month. */
|
|
34
|
+
const CALENDAR_CELLS = 42
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
type View = "days" | "months" | "years"
|
|
37
|
+
type PendingFocus = "day" | "monthTrigger" | "yearTrigger" | "monthGrid" | "yearGrid"
|
|
38
|
+
|
|
39
|
+
const startOfDay = (d: Date) => {
|
|
40
|
+
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
41
|
+
return x
|
|
42
|
+
}
|
|
43
|
+
const monthStart = (year: number, month: number) => new Date(year, month, 1)
|
|
44
|
+
const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1)
|
|
45
|
+
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate()
|
|
46
|
+
const isSameDay = (a: Date, b: Date) =>
|
|
47
|
+
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
48
|
+
const isSameMonth = (a: Date, b: Date) =>
|
|
49
|
+
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
|
50
|
+
const fullDateLabel = (date: Date) => `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generic date-of-birth-friendly calendar.
|
|
54
|
+
*
|
|
55
|
+
* Stable footprint: the six-row day grid is always rendered, so the outer width
|
|
56
|
+
* and content height never change; the month and year choosers render as an
|
|
57
|
+
* overlay ON TOP of that grid rather than replacing it, so a Radix popover never
|
|
58
|
+
* flips sides just because the internal view changed.
|
|
59
|
+
*
|
|
60
|
+
* Month and year are INDEPENDENT pickers: choosing a month keeps the current
|
|
61
|
+
* year and returns to the day grid; choosing a year keeps the current month and
|
|
62
|
+
* returns DIRECTLY to the day grid (it never opens the month grid). Only
|
|
63
|
+
* choosing a day calls `onSelect` (which commits and, in a popover, closes).
|
|
64
|
+
*
|
|
65
|
+
* Keyboard: roving day focus with Arrow (±1 day / ±7 days), Home/End (within the
|
|
66
|
+
* week), PageUp/PageDown (±1 month), and Enter/Space to select — no extra
|
|
67
|
+
* dependency. Exactly one day sits in the tab order at a time.
|
|
68
|
+
*/
|
|
36
69
|
function DatePicker({
|
|
37
70
|
selected,
|
|
38
71
|
onSelect,
|
|
@@ -41,35 +74,28 @@ function DatePicker({
|
|
|
41
74
|
disableFuture = false,
|
|
42
75
|
className,
|
|
43
76
|
}: DatePickerProps) {
|
|
44
|
-
const
|
|
45
|
-
|
|
77
|
+
const today = React.useMemo(() => startOfDay(new Date()), [])
|
|
78
|
+
|
|
79
|
+
const [viewDate, setViewDate] = React.useState(() =>
|
|
80
|
+
monthStart((selected ?? today).getFullYear(), (selected ?? today).getMonth()),
|
|
81
|
+
)
|
|
82
|
+
const [view, setView] = React.useState<View>("days")
|
|
46
83
|
const [yearPageStart, setYearPageStart] = React.useState(
|
|
47
|
-
() => Math.floor((selected
|
|
84
|
+
() => Math.floor((selected ?? today).getFullYear() / YEARS_PER_PAGE) * YEARS_PER_PAGE,
|
|
48
85
|
)
|
|
86
|
+
const [focusedDate, setFocusedDate] = React.useState(() => startOfDay(selected ?? today))
|
|
49
87
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const month = date.getMonth()
|
|
56
|
-
return new Date(year, month + 1, 0).getDate()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const getFirstDayOfMonth = (date: Date) => {
|
|
60
|
-
const year = date.getFullYear()
|
|
61
|
-
const month = date.getMonth()
|
|
62
|
-
return new Date(year, month, 1).getDay()
|
|
63
|
-
}
|
|
88
|
+
const monthTriggerRef = React.useRef<HTMLButtonElement | null>(null)
|
|
89
|
+
const yearTriggerRef = React.useRef<HTMLButtonElement | null>(null)
|
|
90
|
+
const daysGridRef = React.useRef<HTMLDivElement | null>(null)
|
|
91
|
+
const overlayRef = React.useRef<HTMLDivElement | null>(null)
|
|
92
|
+
const pendingFocusRef = React.useRef<PendingFocus | null>(null)
|
|
64
93
|
|
|
65
94
|
const isDateDisabled = (date: Date) => {
|
|
66
|
-
const checkDate =
|
|
67
|
-
checkDate.setHours(0, 0, 0, 0)
|
|
68
|
-
|
|
95
|
+
const checkDate = startOfDay(date)
|
|
69
96
|
if (disablePast && checkDate < today) return true
|
|
70
97
|
if (disableFuture && checkDate > today) return true
|
|
71
98
|
if (disabled && disabled(checkDate)) return true
|
|
72
|
-
|
|
73
99
|
return false
|
|
74
100
|
}
|
|
75
101
|
|
|
@@ -86,222 +112,357 @@ function DatePicker({
|
|
|
86
112
|
return false
|
|
87
113
|
}
|
|
88
114
|
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
91
|
-
|
|
92
|
-
selectedDate.setHours(0, 0, 0, 0)
|
|
93
|
-
const checkDate = new Date(date)
|
|
94
|
-
checkDate.setHours(0, 0, 0, 0)
|
|
95
|
-
return selectedDate.getTime() === checkDate.getTime()
|
|
115
|
+
const rangeAllYearsDisabled = (from: number, to: number) => {
|
|
116
|
+
for (let y = from; y <= to; y++) if (!isYearDisabled(y)) return false
|
|
117
|
+
return true
|
|
96
118
|
}
|
|
97
119
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
120
|
+
const isSelectedDay = (date: Date) => (selected ? isSameDay(startOfDay(selected), startOfDay(date)) : false)
|
|
121
|
+
const isTodayDate = (date: Date) => isSameDay(startOfDay(date), today)
|
|
122
|
+
|
|
123
|
+
/** The best in-month day to seed roving focus: the selected day, then today,
|
|
124
|
+
* then the first available day, then the 1st. */
|
|
125
|
+
const pickInMonthFocus = (vd: Date): Date => {
|
|
126
|
+
const year = vd.getFullYear()
|
|
127
|
+
const month = vd.getMonth()
|
|
128
|
+
if (selected) {
|
|
129
|
+
const s = startOfDay(selected)
|
|
130
|
+
if (isSameMonth(s, vd) && !isDateDisabled(s)) return s
|
|
131
|
+
}
|
|
132
|
+
if (isSameMonth(today, vd) && !isDateDisabled(today)) return new Date(today)
|
|
133
|
+
const dim = daysInMonth(year, month)
|
|
134
|
+
for (let d = 1; d <= dim; d++) {
|
|
135
|
+
const cand = new Date(year, month, d)
|
|
136
|
+
if (!isDateDisabled(cand)) return cand
|
|
137
|
+
}
|
|
138
|
+
return new Date(year, month, 1)
|
|
102
139
|
}
|
|
103
140
|
|
|
104
|
-
|
|
141
|
+
// Resynchronize the visible month/year (and roving focus) when the controlled
|
|
142
|
+
// `selected` prop changes — e.g. the consumer's typed MM/DD/YYYY fields.
|
|
143
|
+
const selectedTime = selected ? startOfDay(selected).getTime() : undefined
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (selectedTime === undefined) return
|
|
146
|
+
const d = new Date(selectedTime)
|
|
147
|
+
setViewDate(monthStart(d.getFullYear(), d.getMonth()))
|
|
148
|
+
setFocusedDate(startOfDay(d))
|
|
149
|
+
}, [selectedTime])
|
|
150
|
+
|
|
151
|
+
// Deliberate focus after every view transition — never left on document.body.
|
|
152
|
+
React.useLayoutEffect(() => {
|
|
153
|
+
const target = pendingFocusRef.current
|
|
154
|
+
if (!target) return
|
|
155
|
+
pendingFocusRef.current = null
|
|
156
|
+
if (target === "monthTrigger") {
|
|
157
|
+
monthTriggerRef.current?.focus()
|
|
158
|
+
} else if (target === "yearTrigger") {
|
|
159
|
+
yearTriggerRef.current?.focus()
|
|
160
|
+
} else if (target === "day") {
|
|
161
|
+
daysGridRef.current?.querySelector<HTMLButtonElement>('[data-roving="true"]')?.focus()
|
|
162
|
+
} else if (target === "monthGrid" || target === "yearGrid") {
|
|
163
|
+
const active = overlayRef.current?.querySelector<HTMLButtonElement>('[data-active="true"]:not([disabled])')
|
|
164
|
+
const fallback = overlayRef.current?.querySelector<HTMLButtonElement>("button:not([disabled])")
|
|
165
|
+
;(active ?? fallback)?.focus()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const generateCalendarDays = (): (Date | null)[] => {
|
|
105
170
|
const year = viewDate.getFullYear()
|
|
106
171
|
const month = viewDate.getMonth()
|
|
107
|
-
const
|
|
108
|
-
const firstDay =
|
|
109
|
-
|
|
172
|
+
const dim = daysInMonth(year, month)
|
|
173
|
+
const firstDay = new Date(year, month, 1).getDay()
|
|
110
174
|
const days: (Date | null)[] = []
|
|
111
|
-
|
|
112
175
|
for (let i = 0; i < firstDay; i++) days.push(null)
|
|
113
|
-
for (let day = 1; day <=
|
|
114
|
-
|
|
176
|
+
for (let day = 1; day <= dim; day++) days.push(new Date(year, month, day))
|
|
177
|
+
while (days.length < CALENDAR_CELLS) days.push(null)
|
|
115
178
|
return days
|
|
116
179
|
}
|
|
117
180
|
|
|
118
|
-
const
|
|
119
|
-
const
|
|
181
|
+
const inViewMonth = isSameMonth(focusedDate, viewDate)
|
|
182
|
+
const rovingDate = inViewMonth ? focusedDate : pickInMonthFocus(viewDate)
|
|
120
183
|
|
|
121
|
-
const
|
|
184
|
+
const commitDay = (date: Date) => {
|
|
122
185
|
if (isDateDisabled(date)) return
|
|
123
186
|
onSelect?.(date)
|
|
124
187
|
}
|
|
125
188
|
|
|
189
|
+
const moveFocusTo = (date: Date) => {
|
|
190
|
+
const d = startOfDay(date)
|
|
191
|
+
setFocusedDate(d)
|
|
192
|
+
if (!isSameMonth(d, viewDate)) setViewDate(monthStart(d.getFullYear(), d.getMonth()))
|
|
193
|
+
pendingFocusRef.current = "day"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** From `origin`, walk `step` days at a time until an available day is found
|
|
197
|
+
* (inclusive of `origin`). Returns null if none within `maxSteps`. */
|
|
198
|
+
const firstAvailableFrom = (origin: Date, step: number, maxSteps: number): Date | null => {
|
|
199
|
+
let d = startOfDay(origin)
|
|
200
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
201
|
+
if (!isDateDisabled(d)) return d
|
|
202
|
+
d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + step)
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handleDaysKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
208
|
+
const base = rovingDate
|
|
209
|
+
let candidate: Date
|
|
210
|
+
let probeStep: number
|
|
211
|
+
|
|
212
|
+
switch (e.key) {
|
|
213
|
+
case "ArrowLeft":
|
|
214
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 1)
|
|
215
|
+
probeStep = -1
|
|
216
|
+
break
|
|
217
|
+
case "ArrowRight":
|
|
218
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1)
|
|
219
|
+
probeStep = 1
|
|
220
|
+
break
|
|
221
|
+
case "ArrowUp":
|
|
222
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() - 7)
|
|
223
|
+
probeStep = -1
|
|
224
|
+
break
|
|
225
|
+
case "ArrowDown":
|
|
226
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 7)
|
|
227
|
+
probeStep = 1
|
|
228
|
+
break
|
|
229
|
+
case "Home":
|
|
230
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() - base.getDay())
|
|
231
|
+
probeStep = 1
|
|
232
|
+
break
|
|
233
|
+
case "End":
|
|
234
|
+
candidate = new Date(base.getFullYear(), base.getMonth(), base.getDate() + (6 - base.getDay()))
|
|
235
|
+
probeStep = -1
|
|
236
|
+
break
|
|
237
|
+
case "PageUp": {
|
|
238
|
+
const prev = addMonths(base, -1)
|
|
239
|
+
candidate = new Date(
|
|
240
|
+
prev.getFullYear(),
|
|
241
|
+
prev.getMonth(),
|
|
242
|
+
Math.min(base.getDate(), daysInMonth(prev.getFullYear(), prev.getMonth())),
|
|
243
|
+
)
|
|
244
|
+
probeStep = 1
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
case "PageDown": {
|
|
248
|
+
const next = addMonths(base, 1)
|
|
249
|
+
candidate = new Date(
|
|
250
|
+
next.getFullYear(),
|
|
251
|
+
next.getMonth(),
|
|
252
|
+
Math.min(base.getDate(), daysInMonth(next.getFullYear(), next.getMonth())),
|
|
253
|
+
)
|
|
254
|
+
probeStep = 1
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
case "Enter":
|
|
258
|
+
case " ":
|
|
259
|
+
e.preventDefault()
|
|
260
|
+
commitDay(base)
|
|
261
|
+
return
|
|
262
|
+
default:
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
let target = firstAvailableFrom(candidate, probeStep, 62)
|
|
268
|
+
if (!target && probeStep !== -1) target = firstAvailableFrom(candidate, -1, 62)
|
|
269
|
+
if (target) moveFocusTo(target)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Header prev/next adapt to the active view; disabled when the whole target
|
|
273
|
+
// range is unavailable so navigation never lands on a dead page.
|
|
274
|
+
const prevMonthDate = addMonths(viewDate, -1)
|
|
275
|
+
const nextMonthDate = addMonths(viewDate, 1)
|
|
276
|
+
|
|
277
|
+
let prevDisabled: boolean
|
|
278
|
+
let nextDisabled: boolean
|
|
279
|
+
let prevLabel: string
|
|
280
|
+
let nextLabel: string
|
|
281
|
+
|
|
282
|
+
if (view === "days") {
|
|
283
|
+
prevDisabled = isMonthDisabled(prevMonthDate.getFullYear(), prevMonthDate.getMonth())
|
|
284
|
+
nextDisabled = isMonthDisabled(nextMonthDate.getFullYear(), nextMonthDate.getMonth())
|
|
285
|
+
prevLabel = "Previous month"
|
|
286
|
+
nextLabel = "Next month"
|
|
287
|
+
} else if (view === "months") {
|
|
288
|
+
prevDisabled = isYearDisabled(viewDate.getFullYear() - 1)
|
|
289
|
+
nextDisabled = isYearDisabled(viewDate.getFullYear() + 1)
|
|
290
|
+
prevLabel = "Previous year"
|
|
291
|
+
nextLabel = "Next year"
|
|
292
|
+
} else {
|
|
293
|
+
prevDisabled = rangeAllYearsDisabled(yearPageStart - YEARS_PER_PAGE, yearPageStart - 1)
|
|
294
|
+
nextDisabled = rangeAllYearsDisabled(yearPageStart + YEARS_PER_PAGE, yearPageStart + YEARS_PER_PAGE * 2 - 1)
|
|
295
|
+
prevLabel = `Previous ${YEARS_PER_PAGE} years`
|
|
296
|
+
nextLabel = `Next ${YEARS_PER_PAGE} years`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const handlePrev = () => {
|
|
300
|
+
if (view === "days") setViewDate(prevMonthDate)
|
|
301
|
+
else if (view === "months") setViewDate(monthStart(viewDate.getFullYear() - 1, viewDate.getMonth()))
|
|
302
|
+
else setYearPageStart((s) => s - YEARS_PER_PAGE)
|
|
303
|
+
}
|
|
304
|
+
const handleNext = () => {
|
|
305
|
+
if (view === "days") setViewDate(nextMonthDate)
|
|
306
|
+
else if (view === "months") setViewDate(monthStart(viewDate.getFullYear() + 1, viewDate.getMonth()))
|
|
307
|
+
else setYearPageStart((s) => s + YEARS_PER_PAGE)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const openMonths = () => {
|
|
311
|
+
if (view === "months") {
|
|
312
|
+
setView("days")
|
|
313
|
+
pendingFocusRef.current = "monthTrigger"
|
|
314
|
+
} else {
|
|
315
|
+
setView("months")
|
|
316
|
+
pendingFocusRef.current = "monthGrid"
|
|
317
|
+
}
|
|
318
|
+
}
|
|
126
319
|
const openYears = () => {
|
|
127
|
-
|
|
128
|
-
|
|
320
|
+
if (view === "years") {
|
|
321
|
+
setView("days")
|
|
322
|
+
pendingFocusRef.current = "yearTrigger"
|
|
323
|
+
} else {
|
|
324
|
+
setYearPageStart(Math.floor(viewDate.getFullYear() / YEARS_PER_PAGE) * YEARS_PER_PAGE)
|
|
325
|
+
setView("years")
|
|
326
|
+
pendingFocusRef.current = "yearGrid"
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Month selection: keep the year, return to days, focus the Month trigger.
|
|
331
|
+
const handleMonthPick = (month: number) => {
|
|
332
|
+
setViewDate(monthStart(viewDate.getFullYear(), month))
|
|
333
|
+
setView("days")
|
|
334
|
+
pendingFocusRef.current = "monthTrigger"
|
|
335
|
+
}
|
|
336
|
+
// Year selection: keep the month, return to days (never months), focus Year.
|
|
337
|
+
const handleYearPick = (year: number) => {
|
|
338
|
+
setViewDate(monthStart(year, viewDate.getMonth()))
|
|
339
|
+
setView("days")
|
|
340
|
+
pendingFocusRef.current = "yearTrigger"
|
|
129
341
|
}
|
|
130
342
|
|
|
131
343
|
const calendarDays = generateCalendarDays()
|
|
132
344
|
const yearPage = Array.from({ length: YEARS_PER_PAGE }, (_, i) => yearPageStart + i)
|
|
345
|
+
const overlayActive = view !== "days"
|
|
346
|
+
|
|
347
|
+
const triggerClass =
|
|
348
|
+
"min-h-[1.5rem] px-1 text-base font-bold text-foreground underline decoration-1 underline-offset-4 decoration-border rounded-sm " +
|
|
349
|
+
"hover:opacity-80 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
350
|
+
|
|
351
|
+
const gridCellClass =
|
|
352
|
+
"h-10 rounded-lg text-sm font-semibold transition-transform flex items-center justify-center " +
|
|
353
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
133
354
|
|
|
134
355
|
return (
|
|
135
356
|
<div className={cn("p-4 bg-card rounded-lg", className)}>
|
|
357
|
+
{/* Header — same footprint in every view. */}
|
|
136
358
|
<div className="flex items-center justify-between mb-4">
|
|
137
359
|
<Button
|
|
138
|
-
variant="
|
|
139
|
-
size="
|
|
140
|
-
className=
|
|
141
|
-
aria-label=
|
|
142
|
-
|
|
360
|
+
variant="ghost"
|
|
361
|
+
size="icon"
|
|
362
|
+
className="h-9 w-9 p-0"
|
|
363
|
+
aria-label={prevLabel}
|
|
364
|
+
disabled={prevDisabled}
|
|
365
|
+
onClick={handlePrev}
|
|
143
366
|
>
|
|
144
|
-
<ChevronLeft className="h-4 w-4" />
|
|
367
|
+
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
145
368
|
</Button>
|
|
146
369
|
|
|
147
|
-
{/* Month and year are EACH pickers: month → 12-month grid; year →
|
|
148
|
-
paged 12-year grid. No single-step year arrows. */}
|
|
149
370
|
<div className="flex items-center gap-1.5">
|
|
150
371
|
<button
|
|
372
|
+
ref={monthTriggerRef}
|
|
151
373
|
type="button"
|
|
152
|
-
onClick={
|
|
374
|
+
onClick={openMonths}
|
|
153
375
|
aria-expanded={view === "months"}
|
|
154
|
-
|
|
376
|
+
aria-label={`Choose month, currently ${MONTHS[viewDate.getMonth()]}`}
|
|
377
|
+
className={triggerClass}
|
|
155
378
|
>
|
|
156
379
|
{MONTHS[viewDate.getMonth()]}
|
|
157
380
|
</button>
|
|
158
381
|
<button
|
|
382
|
+
ref={yearTriggerRef}
|
|
159
383
|
type="button"
|
|
160
|
-
onClick={
|
|
384
|
+
onClick={openYears}
|
|
161
385
|
aria-expanded={view === "years"}
|
|
162
|
-
|
|
386
|
+
aria-label={`Choose year, currently ${viewDate.getFullYear()}`}
|
|
387
|
+
className={triggerClass}
|
|
163
388
|
>
|
|
164
389
|
{viewDate.getFullYear()}
|
|
165
390
|
</button>
|
|
166
391
|
</div>
|
|
167
392
|
|
|
168
393
|
<Button
|
|
169
|
-
variant="
|
|
170
|
-
size="
|
|
171
|
-
className=
|
|
172
|
-
aria-label=
|
|
173
|
-
|
|
394
|
+
variant="ghost"
|
|
395
|
+
size="icon"
|
|
396
|
+
className="h-9 w-9 p-0"
|
|
397
|
+
aria-label={nextLabel}
|
|
398
|
+
disabled={nextDisabled}
|
|
399
|
+
onClick={handleNext}
|
|
174
400
|
>
|
|
175
|
-
<ChevronRight className="h-4 w-4" />
|
|
401
|
+
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
|
176
402
|
</Button>
|
|
177
403
|
</div>
|
|
178
404
|
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
variant="ghost"
|
|
184
|
-
size="sm"
|
|
185
|
-
className="h-8 w-8 p-0"
|
|
186
|
-
aria-label={`Previous ${YEARS_PER_PAGE} years`}
|
|
187
|
-
onClick={() => setYearPageStart((s) => s - YEARS_PER_PAGE)}
|
|
188
|
-
>
|
|
189
|
-
<ChevronLeft className="h-4 w-4" />
|
|
190
|
-
</Button>
|
|
191
|
-
<span className="text-sm font-semibold text-muted-foreground">
|
|
192
|
-
{yearPageStart} – {yearPageStart + YEARS_PER_PAGE - 1}
|
|
193
|
-
</span>
|
|
194
|
-
<Button
|
|
195
|
-
variant="ghost"
|
|
196
|
-
size="sm"
|
|
197
|
-
className="h-8 w-8 p-0"
|
|
198
|
-
aria-label={`Next ${YEARS_PER_PAGE} years`}
|
|
199
|
-
onClick={() => setYearPageStart((s) => s + YEARS_PER_PAGE)}
|
|
200
|
-
>
|
|
201
|
-
<ChevronRight className="h-4 w-4" />
|
|
202
|
-
</Button>
|
|
203
|
-
</div>
|
|
204
|
-
<div className="grid grid-cols-3 gap-1">
|
|
205
|
-
{yearPage.map((year) => {
|
|
206
|
-
const yearDisabled = isYearDisabled(year)
|
|
207
|
-
const isViewYear = year === viewDate.getFullYear()
|
|
208
|
-
return (
|
|
209
|
-
<button
|
|
210
|
-
key={year}
|
|
211
|
-
type="button"
|
|
212
|
-
disabled={yearDisabled}
|
|
213
|
-
onClick={() => {
|
|
214
|
-
setViewDate((prev) => new Date(year, prev.getMonth()))
|
|
215
|
-
setView("months")
|
|
216
|
-
}}
|
|
217
|
-
className={cn(
|
|
218
|
-
"h-10 rounded-lg text-sm font-semibold transition-all",
|
|
219
|
-
"flex items-center justify-center",
|
|
220
|
-
!yearDisabled && !isViewYear && "text-foreground hover:bg-primary/10",
|
|
221
|
-
isViewYear && "bg-primary text-white font-bold shadow-lg",
|
|
222
|
-
yearDisabled && "opacity-30 cursor-not-allowed",
|
|
223
|
-
)}
|
|
224
|
-
>
|
|
225
|
-
{year}
|
|
226
|
-
</button>
|
|
227
|
-
)
|
|
228
|
-
})}
|
|
229
|
-
</div>
|
|
230
|
-
</>
|
|
231
|
-
)}
|
|
232
|
-
|
|
233
|
-
{view === "months" && (
|
|
234
|
-
<div className="grid grid-cols-3 gap-1">
|
|
235
|
-
{MONTHS_SHORT.map((label, month) => {
|
|
236
|
-
const monthDisabled = isMonthDisabled(viewDate.getFullYear(), month)
|
|
237
|
-
const isViewMonth = month === viewDate.getMonth()
|
|
238
|
-
return (
|
|
239
|
-
<button
|
|
240
|
-
key={label}
|
|
241
|
-
type="button"
|
|
242
|
-
disabled={monthDisabled}
|
|
243
|
-
onClick={() => {
|
|
244
|
-
setViewDate((prev) => new Date(prev.getFullYear(), month))
|
|
245
|
-
setView("days")
|
|
246
|
-
}}
|
|
247
|
-
className={cn(
|
|
248
|
-
"h-10 rounded-lg text-sm font-semibold transition-all",
|
|
249
|
-
"flex items-center justify-center",
|
|
250
|
-
!monthDisabled && !isViewMonth && "text-foreground hover:bg-primary/10",
|
|
251
|
-
isViewMonth && "bg-primary text-white font-bold shadow-lg",
|
|
252
|
-
monthDisabled && "opacity-30 cursor-not-allowed",
|
|
253
|
-
)}
|
|
254
|
-
>
|
|
255
|
-
{label}
|
|
256
|
-
</button>
|
|
257
|
-
)
|
|
258
|
-
})}
|
|
259
|
-
</div>
|
|
260
|
-
)}
|
|
405
|
+
{/* One polite live region for the displayed month/year. */}
|
|
406
|
+
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
|
407
|
+
{MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()}
|
|
408
|
+
</div>
|
|
261
409
|
|
|
262
|
-
|
|
263
|
-
|
|
410
|
+
<div className="relative">
|
|
411
|
+
{/* Day grid — ALWAYS rendered so width and height stay stable; inert
|
|
412
|
+
while a chooser overlay covers it. */}
|
|
413
|
+
<div aria-hidden={overlayActive ? true : undefined}>
|
|
264
414
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
265
|
-
{DAYS.map((day) => (
|
|
266
|
-
<div
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
>
|
|
270
|
-
{day}
|
|
415
|
+
{DAYS.map((day, i) => (
|
|
416
|
+
<div key={day} className="text-center text-sm font-bold py-2 text-foreground" aria-hidden="true">
|
|
417
|
+
<span className="sr-only">{DAY_NAMES_LONG[i]}</span>
|
|
418
|
+
<span aria-hidden="true">{day}</span>
|
|
271
419
|
</div>
|
|
272
420
|
))}
|
|
273
421
|
</div>
|
|
274
422
|
|
|
275
|
-
<div
|
|
423
|
+
<div
|
|
424
|
+
ref={daysGridRef}
|
|
425
|
+
className="grid grid-cols-7 gap-1"
|
|
426
|
+
role="group"
|
|
427
|
+
aria-label="Calendar"
|
|
428
|
+
onKeyDown={overlayActive ? undefined : handleDaysKeyDown}
|
|
429
|
+
>
|
|
276
430
|
{calendarDays.map((date, index) => {
|
|
277
|
-
if (!date) return <div key={`empty-${index}`} className="h-9" />
|
|
431
|
+
if (!date) return <div key={`empty-${index}`} className="h-9" aria-hidden="true" />
|
|
278
432
|
|
|
279
433
|
const dateDisabled = isDateDisabled(date)
|
|
280
|
-
const dateSelected =
|
|
281
|
-
const todayDate =
|
|
434
|
+
const dateSelected = isSelectedDay(date)
|
|
435
|
+
const todayDate = isTodayDate(date)
|
|
436
|
+
const roving = isSameDay(date, rovingDate)
|
|
437
|
+
const tabIndex = !overlayActive && roving ? 0 : -1
|
|
282
438
|
|
|
283
439
|
return (
|
|
284
440
|
<button
|
|
285
441
|
key={date.toISOString()}
|
|
286
442
|
type="button"
|
|
287
|
-
onClick={() =>
|
|
443
|
+
onClick={() => commitDay(date)}
|
|
288
444
|
disabled={dateDisabled}
|
|
445
|
+
tabIndex={tabIndex}
|
|
446
|
+
data-roving={roving ? "true" : undefined}
|
|
447
|
+
aria-label={fullDateLabel(date)}
|
|
448
|
+
aria-pressed={dateSelected}
|
|
449
|
+
aria-current={todayDate ? "date" : undefined}
|
|
289
450
|
className={cn(
|
|
290
|
-
"h-9 w-9 rounded-lg text-sm font-semibold transition-
|
|
291
|
-
"hover:scale-105 active:scale-95",
|
|
451
|
+
"h-9 w-9 rounded-lg text-sm font-semibold transition-transform",
|
|
452
|
+
"motion-safe:hover:scale-105 motion-safe:active:scale-95",
|
|
292
453
|
"flex items-center justify-center",
|
|
454
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
293
455
|
!dateDisabled && !dateSelected && !todayDate && "text-foreground hover:bg-primary/10",
|
|
294
|
-
todayDate && !dateSelected && "bg-accent text-foreground font-bold ring-2 ring-border",
|
|
295
|
-
dateSelected && "bg-primary text-
|
|
296
|
-
dateDisabled && "opacity-30 cursor-not-allowed hover:scale-100 hover:bg-transparent",
|
|
297
|
-
dateDisabled && "relative"
|
|
456
|
+
todayDate && !dateSelected && "bg-accent text-accent-foreground font-bold ring-2 ring-border",
|
|
457
|
+
dateSelected && "bg-primary text-primary-foreground font-bold",
|
|
458
|
+
dateDisabled && "opacity-30 cursor-not-allowed motion-safe:hover:scale-100 hover:bg-transparent relative",
|
|
298
459
|
)}
|
|
299
460
|
>
|
|
300
461
|
{date.getDate()}
|
|
301
462
|
{dateDisabled && (
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
</
|
|
463
|
+
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
|
464
|
+
<span className="w-px h-full bg-current transform rotate-45" />
|
|
465
|
+
</span>
|
|
305
466
|
)}
|
|
306
467
|
</button>
|
|
307
468
|
)
|
|
@@ -310,16 +471,16 @@ function DatePicker({
|
|
|
310
471
|
|
|
311
472
|
<div className="mt-4 pt-3 border-t border-border text-xs space-y-1">
|
|
312
473
|
<div className="flex items-center gap-2">
|
|
313
|
-
<div className="h-3 w-3 rounded-sm bg-primary" />
|
|
474
|
+
<div className="h-3 w-3 rounded-sm bg-primary" aria-hidden="true" />
|
|
314
475
|
<span className="text-muted-foreground">Selected</span>
|
|
315
476
|
</div>
|
|
316
477
|
<div className="flex items-center gap-2">
|
|
317
|
-
<div className="h-3 w-3 rounded-sm bg-accent ring-2 ring-border" />
|
|
478
|
+
<div className="h-3 w-3 rounded-sm bg-accent ring-2 ring-border" aria-hidden="true" />
|
|
318
479
|
<span className="text-muted-foreground">Today</span>
|
|
319
480
|
</div>
|
|
320
481
|
{(disablePast || disableFuture || disabled) && (
|
|
321
482
|
<div className="flex items-center gap-2">
|
|
322
|
-
<div className="h-3 w-3 rounded-sm bg-muted opacity-30 relative">
|
|
483
|
+
<div className="h-3 w-3 rounded-sm bg-muted opacity-30 relative" aria-hidden="true">
|
|
323
484
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
324
485
|
<div className="w-px h-full bg-foreground transform rotate-45" />
|
|
325
486
|
</div>
|
|
@@ -328,8 +489,75 @@ function DatePicker({
|
|
|
328
489
|
</div>
|
|
329
490
|
)}
|
|
330
491
|
</div>
|
|
331
|
-
|
|
332
|
-
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
{/* Month / year choosers overlay the day grid — same footprint. */}
|
|
495
|
+
{overlayActive && (
|
|
496
|
+
<div ref={overlayRef} className="absolute inset-0 bg-card rounded-lg flex flex-col justify-center">
|
|
497
|
+
{view === "years" && (
|
|
498
|
+
<div className="mb-2 text-center text-sm font-semibold text-muted-foreground" aria-hidden="true">
|
|
499
|
+
{yearPageStart} – {yearPageStart + YEARS_PER_PAGE - 1}
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
|
|
503
|
+
{view === "months" && (
|
|
504
|
+
<div className="grid grid-cols-3 gap-1">
|
|
505
|
+
{MONTHS_SHORT.map((label, month) => {
|
|
506
|
+
const monthDisabled = isMonthDisabled(viewDate.getFullYear(), month)
|
|
507
|
+
const isViewMonth = month === viewDate.getMonth()
|
|
508
|
+
return (
|
|
509
|
+
<button
|
|
510
|
+
key={label}
|
|
511
|
+
type="button"
|
|
512
|
+
disabled={monthDisabled}
|
|
513
|
+
data-active={isViewMonth ? "true" : undefined}
|
|
514
|
+
aria-pressed={isViewMonth}
|
|
515
|
+
aria-label={`${MONTHS[month]} ${viewDate.getFullYear()}`}
|
|
516
|
+
onClick={() => handleMonthPick(month)}
|
|
517
|
+
className={cn(
|
|
518
|
+
gridCellClass,
|
|
519
|
+
!monthDisabled && !isViewMonth && "text-foreground hover:bg-primary/10",
|
|
520
|
+
isViewMonth && "bg-primary text-primary-foreground font-bold",
|
|
521
|
+
monthDisabled && "opacity-30 cursor-not-allowed",
|
|
522
|
+
)}
|
|
523
|
+
>
|
|
524
|
+
{label}
|
|
525
|
+
</button>
|
|
526
|
+
)
|
|
527
|
+
})}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
|
|
531
|
+
{view === "years" && (
|
|
532
|
+
<div className="grid grid-cols-3 gap-1">
|
|
533
|
+
{yearPage.map((year) => {
|
|
534
|
+
const yearDisabled = isYearDisabled(year)
|
|
535
|
+
const isViewYear = year === viewDate.getFullYear()
|
|
536
|
+
return (
|
|
537
|
+
<button
|
|
538
|
+
key={year}
|
|
539
|
+
type="button"
|
|
540
|
+
disabled={yearDisabled}
|
|
541
|
+
data-active={isViewYear ? "true" : undefined}
|
|
542
|
+
aria-pressed={isViewYear}
|
|
543
|
+
aria-label={`Year ${year}`}
|
|
544
|
+
onClick={() => handleYearPick(year)}
|
|
545
|
+
className={cn(
|
|
546
|
+
gridCellClass,
|
|
547
|
+
!yearDisabled && !isViewYear && "text-foreground hover:bg-primary/10",
|
|
548
|
+
isViewYear && "bg-primary text-primary-foreground font-bold",
|
|
549
|
+
yearDisabled && "opacity-30 cursor-not-allowed",
|
|
550
|
+
)}
|
|
551
|
+
>
|
|
552
|
+
{year}
|
|
553
|
+
</button>
|
|
554
|
+
)
|
|
555
|
+
})}
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
333
561
|
</div>
|
|
334
562
|
)
|
|
335
563
|
}
|