@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.
@@ -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
- /** Calendar with fast month/year navigation: the header's month and year are
32
- * each buttons — month opens a 12-month grid, year opens a paged 12-year
33
- * grid (no single-step year arrows; distant years are a couple of clicks).
34
- * Picking a year drills to the month grid, then the day grid — the natural
35
- * date-of-birth path — while picking just a month returns straight to days. */
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 [viewDate, setViewDate] = React.useState(() => selected || new Date())
45
- const [view, setView] = React.useState<"days" | "months" | "years">("days")
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 || new Date()).getFullYear() / YEARS_PER_PAGE) * YEARS_PER_PAGE,
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 today = new Date()
51
- today.setHours(0, 0, 0, 0)
52
-
53
- const getDaysInMonth = (date: Date) => {
54
- const year = date.getFullYear()
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 = new Date(date)
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 isDateSelected = (date: Date) => {
90
- if (!selected) return false
91
- const selectedDate = new Date(selected)
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 isToday = (date: Date) => {
99
- const checkDate = new Date(date)
100
- checkDate.setHours(0, 0, 0, 0)
101
- return checkDate.getTime() === today.getTime()
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
- const generateCalendarDays = () => {
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 daysInMonth = getDaysInMonth(viewDate)
108
- const firstDay = getFirstDayOfMonth(viewDate)
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 <= daysInMonth; day++) days.push(new Date(year, month, 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 previousMonth = () => setViewDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1))
119
- const nextMonth = () => setViewDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1))
181
+ const inViewMonth = isSameMonth(focusedDate, viewDate)
182
+ const rovingDate = inViewMonth ? focusedDate : pickInMonthFocus(viewDate)
120
183
 
121
- const handleDateClick = (date: Date) => {
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
- setYearPageStart(Math.floor(viewDate.getFullYear() / YEARS_PER_PAGE) * YEARS_PER_PAGE)
128
- setView("years")
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="default"
139
- size="sm"
140
- className={cn("h-8 w-8 p-0", view !== "days" && "invisible")}
141
- aria-label="Previous month"
142
- onClick={previousMonth}
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={() => setView(view === "months" ? "days" : "months")}
374
+ onClick={openMonths}
153
375
  aria-expanded={view === "months"}
154
- className="text-base font-bold hover:opacity-80 transition-opacity text-foreground underline decoration-1 underline-offset-4 decoration-border"
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={() => (view === "years" ? setView("days") : openYears())}
384
+ onClick={openYears}
161
385
  aria-expanded={view === "years"}
162
- className="text-base font-bold hover:opacity-80 transition-opacity text-foreground underline decoration-1 underline-offset-4 decoration-border"
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="default"
170
- size="sm"
171
- className={cn("h-8 w-8 p-0", view !== "days" && "invisible")}
172
- aria-label="Next month"
173
- onClick={nextMonth}
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
- {view === "years" && (
180
- <>
181
- <div className="flex items-center justify-between mb-2">
182
- <Button
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
- {view === "days" && (
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
- key={day}
268
- className="text-center text-sm font-bold py-2 text-foreground"
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 className="grid grid-cols-7 gap-1">
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 = isDateSelected(date)
281
- const todayDate = isToday(date)
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={() => handleDateClick(date)}
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-all",
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-white font-bold shadow-lg",
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
- <div className="absolute inset-0 flex items-center justify-center">
303
- <div className="w-px h-full bg-current transform rotate-45" />
304
- </div>
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
  }