@cahyo-dimas/freeday 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/COMPONENTS.md +214 -18
  3. package/README.id.md +4 -4
  4. package/README.md +4 -3
  5. package/adapters/blazor/FdyDatepicker.razor +3 -1
  6. package/adapters/blazor/FdyDatepicker.razor.cs +36 -1
  7. package/adapters/blazor/FdyMonthpicker.razor +29 -0
  8. package/adapters/blazor/FdyMonthpicker.razor.cs +102 -0
  9. package/adapters/blazor/FdyTable.razor +6 -1
  10. package/adapters/blazor/FdyTable.razor.cs +33 -0
  11. package/adapters/blazor/FdyTableFooter.razor +6 -6
  12. package/adapters/blazor/FdyTableFooter.razor.cs +36 -0
  13. package/adapters/core/month-grid.d.ts +116 -0
  14. package/adapters/core/month-grid.js +234 -0
  15. package/adapters/react/components/FdyDatepicker.tsx +164 -166
  16. package/adapters/react/components/FdyMonthpicker.tsx +400 -0
  17. package/adapters/react/components/FdyTable.tsx +38 -2
  18. package/adapters/react/components/FdyTableFilter.tsx +13 -1
  19. package/adapters/react/components/FdyTableFooter.tsx +37 -6
  20. package/adapters/react/index.d.ts +6 -2
  21. package/adapters/react/index.js +1 -0
  22. package/adapters/react/usePopover.ts +54 -9
  23. package/adapters/vue/components/FdyDatepicker.vue +158 -160
  24. package/adapters/vue/components/FdyMonthpicker.vue +387 -0
  25. package/adapters/vue/components/FdyTable.vue +33 -1
  26. package/adapters/vue/components/FdyTableFilter.vue +13 -1
  27. package/adapters/vue/components/FdyTableFooter.vue +39 -6
  28. package/adapters/vue/index.d.ts +16 -0
  29. package/adapters/vue/index.js +1 -0
  30. package/adapters/vue/usePopover.ts +53 -10
  31. package/dist/freeday-datepicker.js +68 -14
  32. package/dist/freeday-menu.js +15 -1
  33. package/dist/freeday-popover.js +33 -11
  34. package/dist/freeday-table.js +64 -5
  35. package/dist/freeday-tabs.js +21 -2
  36. package/dist/freeday.bundle.css +50 -2
  37. package/dist/freeday.css +50 -2
  38. package/dist/freeday.js +201 -33
  39. package/docs/agent-onboarding.md +3 -3
  40. package/docs/getting-started.md +6 -6
  41. package/docs/integrations.md +22 -4
  42. package/package.json +2 -2
  43. package/src/components/table.css +19 -2
  44. package/src/components/tabs.css +31 -0
@@ -1,6 +1,31 @@
1
1
  import type { JSX } from 'react';
2
2
  import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
3
  import { usePopover } from '../usePopover';
4
+ /* The month and year grids, which `FdyMonthpicker` reads from the same module. They were written
5
+ here and moved out when the month picker arrived: a second copy of the bounds rule is a second
6
+ place to fix it, and this release spent three items on exactly that failure in the panel-position
7
+ logic (#079). What the two components do differently with the grid stays in each of them, which
8
+ is one branch: whether choosing a month commits or walks down into its days. */
9
+ import {
10
+ YEARS_PER_PAGE,
11
+ addMonths,
12
+ monthCells as buildMonthCells,
13
+ monthDisabled,
14
+ monthFormatters,
15
+ monthKeyIntent,
16
+ pageLocale,
17
+ parseISOMonth,
18
+ startOfDay,
19
+ startOfMonth,
20
+ toISOMonth,
21
+ yearCells as buildYearCells,
22
+ yearKeyIntent,
23
+ yearPageStart,
24
+ yearRangeLabel,
25
+ type FdyMonthCell,
26
+ type FdyMonthFormatters,
27
+ type FdyYearCell,
28
+ } from '../../core/month-grid.js';
4
29
 
5
30
  // React port of adapters/vue/components/FdyDatepicker.vue over freeday's `.fdy-datepicker` /
6
31
  // `.fdy-cal__*` classes (see src/components/datepicker.css). Re-implements the single-date
@@ -9,6 +34,12 @@ import { usePopover } from '../usePopover';
9
34
  // (outside-click + focusout) mirrors the already-approved FdyCombo.tsx pattern. Range mode is
10
35
  // out of scope (v1 = single date).
11
36
 
37
+ /** Which grid is on screen. The drill goes days -> months -> years and back down. */
38
+ type CalendarLevel = 'days' | 'months' | 'years';
39
+
40
+ /** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
41
+ export type FdyDatepickerMode = 'day' | 'month';
42
+
12
43
  interface DayCell {
13
44
  date: Date;
14
45
  iso: string;
@@ -21,27 +52,20 @@ interface DayCell {
21
52
  focusable: boolean;
22
53
  }
23
54
 
24
- interface YearCell {
25
- year: number;
26
- selected: boolean;
27
- today: boolean;
28
- disabled: boolean;
29
- focusable: boolean;
30
- }
31
-
32
- interface MonthCell {
33
- index: number;
34
- label: string;
35
- ariaLabel: string;
36
- selected: boolean;
37
- today: boolean;
38
- disabled: boolean;
39
- focusable: boolean;
40
- }
41
-
42
55
  export interface FdyDatepickerProps {
56
+ /** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
43
57
  value: string | null;
44
58
  onChange: (value: string) => void;
59
+ /**
60
+ * `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and
61
+ * answers with `YYYY-MM`.
62
+ *
63
+ * @deprecated Use `FdyMonthpicker` for a month. This prop makes `value`, `min` and `max` carry
64
+ * two different formats while a *different* prop decides which, so the type cannot state what to
65
+ * store in them. `FdyMonthpicker` has one format per prop and no mode to read. Still works,
66
+ * unchanged, and removed in 4.0.
67
+ */
68
+ mode?: FdyDatepickerMode;
45
69
  min?: string;
46
70
  max?: string;
47
71
  locale?: string;
@@ -89,12 +113,6 @@ function parseISO(s: string | null | undefined): Date | null {
89
113
  const d: Date = new Date(y, m - 1, day);
90
114
  return Number.isNaN(d.getTime()) ? null : d;
91
115
  }
92
- function startOfDay(d: Date): Date {
93
- return new Date(d.getFullYear(), d.getMonth(), d.getDate());
94
- }
95
- function startOfMonth(d: Date): Date {
96
- return new Date(d.getFullYear(), d.getMonth(), 1);
97
- }
98
116
  function sameDay(a: Date | null, b: Date | null): boolean {
99
117
  return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
100
118
  }
@@ -103,11 +121,6 @@ function addDays(d: Date, n: number): Date {
103
121
  x.setDate(x.getDate() + n);
104
122
  return x;
105
123
  }
106
- function addMonths(d: Date, n: number): Date {
107
- const x: Date = new Date(d);
108
- x.setMonth(x.getMonth() + n);
109
- return x;
110
- }
111
124
 
112
125
  function dayClassName(cell: DayCell): string {
113
126
  let cls = 'fdy-cal__day';
@@ -131,18 +144,23 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
131
144
  // don't steal DOM focus off the button, matching the Vue template's behavior.
132
145
  const pendingFocusRef = useRef<string | null>(null);
133
146
 
147
+ const isMonthMode: boolean = props.mode === 'month';
148
+ /** Resolved once so every formatter below reads the same tag. */
149
+ const activeLocale: string = props.locale ?? pageLocale();
150
+ const parseValue = isMonthMode ? parseISOMonth : parseISO;
151
+
134
152
  const [open, setOpen] = useState<boolean>(false);
135
- /* 'days' | 'months', the calendar drills one level up instead of growing furniture beside the
136
- title. Before this the only pointer route to another month was one click per month. */
137
- const [mode, setMode] = useState<'days' | 'months' | 'years'>('days');
153
+ /* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
154
+ the title. Before this the only pointer route to another month was one click per month.
155
+ Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with.
156
+
157
+ Month mode has no level below months, so the day grid is unreachable by construction rather
158
+ than by every path remembering not to ask for it. */
159
+ const startLevel = (): CalendarLevel => (props.mode === 'month' ? 'months' : 'days');
160
+ const [level, setLevel] = useState<CalendarLevel>(startLevel);
138
161
  const [focusMonth, setFocusMonth] = useState<number>(0);
139
162
  const [focusYear, setFocusYear] = useState<number>(0);
140
163
 
141
- /* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring on the year in
142
- view would make the arrows land on overlapping windows, and the same year would sit somewhere
143
- different every step. */
144
- const YEARS_PER_PAGE = 12;
145
- const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
146
164
  const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
147
165
  const pendingMonthFocusRef = useRef<number | null>(null);
148
166
  const yearRefs = useRef<Record<number, HTMLButtonElement | null>>({});
@@ -157,13 +175,13 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
157
175
  panelRef.current?.setAttribute('popover', 'manual');
158
176
  }, []);
159
177
 
160
- const selectedDate: Date | null = useMemo((): Date | null => parseISO(props.value), [props.value]);
161
- const minDate: Date | null = useMemo((): Date | null => parseISO(props.min), [props.min]);
162
- const maxDate: Date | null = useMemo((): Date | null => parseISO(props.max), [props.max]);
178
+ const selectedDate: Date | null = useMemo((): Date | null => parseValue(props.value), [props.value, isMonthMode]);
179
+ const minDate: Date | null = useMemo((): Date | null => parseValue(props.min), [props.min, isMonthMode]);
180
+ const maxDate: Date | null = useMemo((): Date | null => parseValue(props.max), [props.max, isMonthMode]);
163
181
  const isDisabled: boolean = props.disabled === true;
164
182
  const isReadonly: boolean = props.readonly === true;
165
183
  const isInvalid: boolean = props.invalid === true;
166
- const displayPlaceholder: string = props.placeholder ?? 'Select date';
184
+ const displayPlaceholder: string = props.placeholder ?? (isMonthMode ? 'Select month' : 'Select date');
167
185
  const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
168
186
  const chooseMonthLabelText: string = props.chooseMonthLabel ?? 'Choose month';
169
187
  const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
@@ -171,20 +189,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
171
189
  const chooseYearLabelText: string = props.chooseYearLabel ?? 'Choose year';
172
190
  const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
173
191
  const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
174
- const monthCellFmt: Intl.DateTimeFormat = useMemo(
175
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
176
- [props.locale],
177
- );
178
- const monthNameFmt: Intl.DateTimeFormat = useMemo(
179
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
180
- [props.locale],
192
+ const formatters: FdyMonthFormatters = useMemo(
193
+ (): FdyMonthFormatters => monthFormatters(activeLocale),
194
+ [activeLocale],
181
195
  );
182
196
  const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
183
197
  const nextMonthLabelText: string = props.nextMonthLabel ?? 'Next month';
184
198
  const clearLabelText: string = props.clearLabel ?? 'Clear date';
185
199
 
186
- const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseISO(props.value) ?? new Date()));
187
- const [focusDate, setFocusDate] = useState<Date>((): Date => parseISO(props.value) ?? new Date());
200
+ const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseValue(props.value) ?? new Date()));
201
+ const [focusDate, setFocusDate] = useState<Date>((): Date => parseValue(props.value) ?? new Date());
188
202
 
189
203
  // Keep the roving-tabindex cell inside the visible month so the grid stays keyboard-reachable
190
204
  // after navigating with Home/End/PageUp/PageDown (mirrors freeday-datepicker.js's clamp).
@@ -195,23 +209,28 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
195
209
  }, [focusDate, viewMonth]);
196
210
 
197
211
  const monthFmt: Intl.DateTimeFormat = useMemo(
198
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
199
- [props.locale],
212
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'long', year: 'numeric' }),
213
+ [activeLocale],
200
214
  );
201
215
  const valueFmt: Intl.DateTimeFormat = useMemo(
202
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { day: '2-digit', month: 'short', year: 'numeric' }),
203
- [props.locale],
216
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { day: '2-digit', month: 'short', year: 'numeric' }),
217
+ [activeLocale],
204
218
  );
205
219
  const dayLabelFmt: Intl.DateTimeFormat = useMemo(
206
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
207
- [props.locale],
220
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
221
+ [activeLocale],
208
222
  );
209
223
  const dowFmt: Intl.DateTimeFormat = useMemo(
210
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'short' }),
211
- [props.locale],
224
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'short' }),
225
+ [activeLocale],
212
226
  );
213
227
 
214
- const displayValue: string = selectedDate !== null ? valueFmt.format(selectedDate) : displayPlaceholder;
228
+ /* Month mode shows the month through the SAME formatter the month cells and the day grid's title
229
+ already use, which is the whole point of the mode: <input type="month"> names the month in the
230
+ browser's language, this names it in the page's. */
231
+ const displayValue: string = selectedDate === null
232
+ ? displayPlaceholder
233
+ : (isMonthMode ? formatters.name : valueFmt).format(selectedDate);
215
234
 
216
235
  const weekdayHeaders: string[] = useMemo((): string[] => {
217
236
  const monday: Date = new Date(2024, 0, 1); // a known Monday
@@ -224,36 +243,22 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
224
243
  return false;
225
244
  }
226
245
 
227
- /** A month is only unreachable when the WHOLE month falls outside min/max, a range that sits
228
- * inside one month disables both ends while the middle is perfectly selectable. */
229
- function isMonthDisabled(year: number, month: number): boolean {
230
- const first: Date = new Date(year, month, 1);
231
- const last: Date = new Date(year, month + 1, 0);
232
- if (minDate !== null && startOfDay(last).getTime() < startOfDay(minDate).getTime()) return true;
233
- if (maxDate !== null && startOfDay(first).getTime() > startOfDay(maxDate).getTime()) return true;
234
- return false;
235
- }
236
-
237
- const monthCells: MonthCell[] = useMemo((): MonthCell[] => {
238
- const year: number = viewMonth.getFullYear();
239
- const today: Date = new Date();
240
- const sel: Date | null = parseISO(props.value);
241
- return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
242
- const cellDate: Date = new Date(year, index, 1);
243
- return {
244
- index,
245
- label: monthCellFmt.format(cellDate),
246
- ariaLabel: monthNameFmt.format(cellDate),
247
- selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
248
- today: today.getFullYear() === year && today.getMonth() === index,
249
- disabled: isMonthDisabled(year, index),
250
- focusable: index === focusMonth,
251
- };
252
- });
253
- // eslint-disable-next-line react-hooks/exhaustive-deps
254
- }, [viewMonth, props.value, focusMonth, monthCellFmt, monthNameFmt, minDate, maxDate]);
246
+ const monthCells: FdyMonthCell[] = useMemo((): FdyMonthCell[] => buildMonthCells({
247
+ year: viewMonth.getFullYear(),
248
+ selected: parseValue(props.value),
249
+ min: minDate,
250
+ max: maxDate,
251
+ focusMonth,
252
+ formatters,
253
+ }),
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ [viewMonth, props.value, focusMonth, formatters, minDate, maxDate]);
255
256
 
256
257
  const gridCells: DayCell[] = useMemo((): DayCell[] => {
258
+ /* Month mode never mounts this grid, and useMemo runs whether or not its result is read, so
259
+ without this every arrow key in the month grid would format forty two day labels through Intl
260
+ for cells nobody sees. Vue needs no such guard: a computed is lazy. */
261
+ if (isMonthMode) return [];
257
262
  const first: Date = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
258
263
  const offset: number = (first.getDay() + 6) % 7; // Monday-first
259
264
  const startCell: Date = addDays(first, -offset);
@@ -278,7 +283,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
278
283
  // Deps intentionally mirror the port recipe, not every closure read: minDate/maxDate,
279
284
  // dayLabelFmt and effectiveFocusDate all derive deterministically from the listed deps
280
285
  // within the same render pass, so this stays in sync without listing them separately.
281
- }, [viewMonth, props.value, props.min, props.max, props.locale]);
286
+ }, [viewMonth, props.value, props.min, props.max, activeLocale, isMonthMode]);
282
287
 
283
288
  // Focus the roving-tabindex cell after a render triggered by moveFocus/openPanel (React's
284
289
  // analogue of Vue's `nextTick(focusCell)`). Runs after every render; cheap no-op otherwise.
@@ -305,10 +310,27 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
305
310
 
306
311
  function openMonthGrid(): void {
307
312
  setFocusMonth(viewMonth.getMonth());
308
- setMode('months');
313
+ setLevel('months');
309
314
  pendingMonthFocusRef.current = viewMonth.getMonth();
310
315
  }
311
316
 
317
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
318
+ day mode a month is a step on the way down; in month mode it is the answer. */
319
+ function chooseMonth(index: number): void {
320
+ if (isMonthMode) pickMonth(index);
321
+ else openMonth(index);
322
+ }
323
+
324
+ function pickMonth(index: number): void {
325
+ const year: number = viewMonth.getFullYear();
326
+ if (monthDisabled(year, index, minDate, maxDate)) return;
327
+ const first: Date = new Date(year, index, 1);
328
+ setFocusMonth(index);
329
+ setFocusDate(first);
330
+ props.onChange(toISOMonth(first));
331
+ closePanel(true);
332
+ }
333
+
312
334
  /* Picking a month is NAVIGATION, not selection: back to the day grid with the roving cell clamped
313
335
  into the new month, so nothing is committed until a day is chosen. */
314
336
  function openMonth(index: number): void {
@@ -317,47 +339,33 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
317
339
  const next: Date = new Date(year, index, Math.min(focusDate.getDate(), daysInMonth));
318
340
  setViewMonth(new Date(year, index, 1));
319
341
  setFocusDate(next);
320
- setMode('days');
342
+ setLevel('days');
321
343
  pendingFocusRef.current = toISO(next);
322
344
  }
323
345
 
346
+ /** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
324
347
  function moveMonthFocus(index: number, yearDelta: number): void {
325
348
  if (yearDelta !== 0) setViewMonth(addMonths(viewMonth, yearDelta * 12));
326
- const next: number = (index + 12) % 12;
327
- setFocusMonth(next);
328
- pendingMonthFocusRef.current = next;
329
- }
330
-
331
- /** A year is unreachable only when EVERY month in it falls outside min/max. */
332
- function isYearDisabled(year: number): boolean {
333
- if (minDate !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate).getTime()) return true;
334
- if (maxDate !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate).getTime()) return true;
335
- return false;
349
+ setFocusMonth(index);
350
+ pendingMonthFocusRef.current = index;
336
351
  }
337
352
 
338
353
  const yearPage: number = yearPageStart(focusYear);
339
- const yearRangeLabel: string = `${yearPage} – ${yearPage + YEARS_PER_PAGE - 1}`;
340
-
341
- const yearCells: YearCell[] = useMemo((): YearCell[] => {
342
- const start: number = yearPageStart(focusYear);
343
- const thisYear: number = new Date().getFullYear();
344
- const sel: Date | null = parseISO(props.value);
345
- return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
346
- const year: number = start + index;
347
- return {
348
- year,
349
- selected: sel !== null && sel.getFullYear() === year,
350
- today: thisYear === year,
351
- disabled: isYearDisabled(year),
352
- focusable: year === focusYear,
353
- };
354
- });
355
- // eslint-disable-next-line react-hooks/exhaustive-deps
356
- }, [focusYear, props.value, minDate, maxDate]);
354
+ const yearRangeText: string = yearRangeLabel(yearPage);
355
+
356
+ const yearCells: FdyYearCell[] = useMemo((): FdyYearCell[] => buildYearCells({
357
+ pageStart: yearPageStart(focusYear),
358
+ selected: parseValue(props.value),
359
+ min: minDate,
360
+ max: maxDate,
361
+ focusYear,
362
+ }),
363
+ // eslint-disable-next-line react-hooks/exhaustive-deps
364
+ [focusYear, props.value, minDate, maxDate]);
357
365
 
358
366
  function openYearGrid(): void {
359
367
  setFocusYear(viewMonth.getFullYear());
360
- setMode('years');
368
+ setLevel('years');
361
369
  pendingYearFocusRef.current = viewMonth.getFullYear();
362
370
  }
363
371
 
@@ -366,7 +374,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
366
374
  function openYear(year: number): void {
367
375
  setViewMonth(new Date(year, viewMonth.getMonth(), 1));
368
376
  setFocusMonth(viewMonth.getMonth());
369
- setMode('months');
377
+ setLevel('months');
370
378
  pendingMonthFocusRef.current = viewMonth.getMonth();
371
379
  }
372
380
 
@@ -377,41 +385,22 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
377
385
  }
378
386
 
379
387
  function onYearKeydown(e: React.KeyboardEvent): void {
380
- let handled: boolean = true;
381
- switch (e.key) {
382
- case 'ArrowLeft': moveYearFocus(focusYear - 1); break;
383
- case 'ArrowRight': moveYearFocus(focusYear + 1); break;
384
- case 'ArrowUp': moveYearFocus(focusYear - 3); break;
385
- case 'ArrowDown': moveYearFocus(focusYear + 3); break;
386
- case 'Home': moveYearFocus(yearPageStart(focusYear)); break;
387
- case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
388
- case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
389
- case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
390
- case 'Enter':
391
- case ' ': openYear(focusYear); break;
392
- case 'Escape': closePanel(true); break;
393
- default: handled = false;
394
- }
395
- if (handled) { e.preventDefault(); e.stopPropagation(); }
388
+ const intent = yearKeyIntent(e.key, focusYear);
389
+ if (intent.action === 'none') return;
390
+ e.preventDefault();
391
+ e.stopPropagation();
392
+ if (intent.action === 'focus') moveYearFocus(intent.year);
393
+ else if (intent.action === 'choose') openYear(intent.year);
394
+ else closePanel(true);
396
395
  }
397
396
 
398
397
  function onMonthKeydown(e: React.KeyboardEvent): void {
399
- let handled: boolean = true;
400
- switch (e.key) {
401
- case 'ArrowLeft': moveMonthFocus(focusMonth - 1, 0); break;
402
- case 'ArrowRight': moveMonthFocus(focusMonth + 1, 0); break;
403
- case 'ArrowUp': moveMonthFocus(focusMonth - 3, 0); break;
404
- case 'ArrowDown': moveMonthFocus(focusMonth + 3, 0); break;
405
- case 'Home': moveMonthFocus(0, 0); break;
406
- case 'End': moveMonthFocus(11, 0); break;
407
- case 'PageUp': moveMonthFocus(focusMonth, -1); break;
408
- case 'PageDown': moveMonthFocus(focusMonth, 1); break;
409
- case 'Enter':
410
- case ' ': openMonth(focusMonth); break;
411
- case 'Escape': closePanel(true); break;
412
- default: handled = false;
413
- }
414
- if (handled) e.preventDefault();
398
+ const intent = monthKeyIntent(e.key, focusMonth);
399
+ if (intent.action === 'none') return;
400
+ e.preventDefault();
401
+ if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
402
+ else if (intent.action === 'choose') chooseMonth(intent.index);
403
+ else closePanel(true);
415
404
  }
416
405
 
417
406
  function moveFocus(next: Date): void {
@@ -441,7 +430,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
441
430
  const next: Date = selectedDate ?? focusDate ?? new Date();
442
431
  setFocusDate(next);
443
432
  setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
433
+ /* Reopening starts at the mode's own floor. Without this a month picker that drilled to the
434
+ year grid and was dismissed would open on years the next time, and a keyboard user would
435
+ land on a grid that cannot answer. */
436
+ setLevel(startLevel());
444
437
  setOpen(true);
438
+ if (isMonthMode) {
439
+ setFocusMonth(next.getMonth());
440
+ pendingMonthFocusRef.current = next.getMonth();
441
+ return;
442
+ }
445
443
  pendingFocusRef.current = toISO(next);
446
444
  }
447
445
 
@@ -575,10 +573,10 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
575
573
  <button
576
574
  type="button"
577
575
  className="fdy-cal__nav"
578
- aria-label={mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText}
576
+ aria-label={level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText}
579
577
  onClick={(): void => {
580
- if (mode === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
581
- else if (mode === 'months') moveMonthFocus(focusMonth, -1);
578
+ if (level === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
579
+ else if (level === 'months') moveMonthFocus(focusMonth, -1);
582
580
  else setViewMonth(addMonths(viewMonth, -1));
583
581
  }}
584
582
  >
@@ -588,27 +586,27 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
588
586
  id={titleId}
589
587
  type="button"
590
588
  className="fdy-cal__title"
591
- aria-label={mode === 'years' ? undefined : mode === 'months' ? chooseYearLabelText : chooseMonthLabelText}
592
- onClick={(): void => { if (mode === 'months') openYearGrid(); else openMonthGrid(); }}
589
+ aria-label={level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText}
590
+ onClick={(): void => { if (level === 'months') openYearGrid(); else openMonthGrid(); }}
593
591
  >
594
- {mode === 'years' ? yearRangeLabel : mode === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
592
+ {level === 'years' ? yearRangeText : level === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
595
593
  </button>
596
594
  <button
597
595
  type="button"
598
596
  className="fdy-cal__nav"
599
- aria-label={mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText}
597
+ aria-label={level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText}
600
598
  onClick={(): void => {
601
- if (mode === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
602
- else if (mode === 'months') moveMonthFocus(focusMonth, 1);
599
+ if (level === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
600
+ else if (level === 'months') moveMonthFocus(focusMonth, 1);
603
601
  else setViewMonth(addMonths(viewMonth, 1));
604
602
  }}
605
603
  >
606
604
  &rsaquo;
607
605
  </button>
608
606
  </div>
609
- {mode === 'years' ? (
607
+ {level === 'years' ? (
610
608
  <div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
611
- {yearCells.map((cell: YearCell) => (
609
+ {yearCells.map((cell: FdyYearCell): JSX.Element => (
612
610
  <button
613
611
  key={cell.year}
614
612
  ref={(el: HTMLButtonElement | null): void => { yearRefs.current[cell.year] = el; }}
@@ -625,9 +623,9 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
625
623
  </button>
626
624
  ))}
627
625
  </div>
628
- ) : mode === 'months' ? (
626
+ ) : level === 'months' ? (
629
627
  <div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
630
- {monthCells.map((cell: MonthCell) => (
628
+ {monthCells.map((cell: FdyMonthCell): JSX.Element => (
631
629
  <button
632
630
  key={cell.index}
633
631
  ref={(el: HTMLButtonElement | null): void => { monthRefs.current[cell.index] = el; }}
@@ -638,7 +636,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
638
636
  aria-selected={cell.selected ? true : undefined}
639
637
  disabled={cell.disabled}
640
638
  tabIndex={cell.focusable ? 0 : -1}
641
- onClick={(): void => openMonth(cell.index)}
639
+ onClick={(): void => chooseMonth(cell.index)}
642
640
  >
643
641
  {cell.label}
644
642
  </button>