@cahyo-dimas/freeday 3.6.0 → 3.8.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 (52) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/COMPONENTS.md +119 -6
  3. package/README.id.md +19 -6
  4. package/README.md +17 -5
  5. package/USAGE.md +42 -0
  6. package/adapters/blazor/FdyDatepicker.razor +3 -1
  7. package/adapters/blazor/FdyDatepicker.razor.cs +22 -1
  8. package/adapters/blazor/FdyTableFooter.razor +6 -6
  9. package/adapters/blazor/FdyTableFooter.razor.cs +36 -0
  10. package/adapters/react/components/FdyDatepicker.tsx +122 -42
  11. package/adapters/react/components/FdyTableFooter.tsx +37 -6
  12. package/adapters/react/index.d.ts +1 -1
  13. package/adapters/vue/components/FdyDatepicker.vue +119 -34
  14. package/adapters/vue/components/FdyTableFooter.vue +39 -6
  15. package/dist/freeday-datepicker.js +68 -14
  16. package/dist/freeday-menu.js +15 -1
  17. package/dist/freeday-popover.js +33 -11
  18. package/dist/freeday-tabs.js +21 -2
  19. package/dist/freeday.bundle.css +493 -117
  20. package/dist/freeday.css +190 -113
  21. package/dist/freeday.js +137 -28
  22. package/dist/freeday.tokens.css +303 -4
  23. package/docs/getting-started.md +1 -1
  24. package/package.json +2 -2
  25. package/src/base.css +1 -1
  26. package/src/components/accordion.css +1 -1
  27. package/src/components/alert.css +5 -5
  28. package/src/components/app-shell.css +2 -2
  29. package/src/components/appbar.css +3 -3
  30. package/src/components/avatar.css +15 -9
  31. package/src/components/badge.css +18 -14
  32. package/src/components/breadcrumb.css +1 -1
  33. package/src/components/button.css +21 -13
  34. package/src/components/carousel.css +3 -3
  35. package/src/components/cascade.css +4 -4
  36. package/src/components/chip.css +33 -15
  37. package/src/components/combo.css +5 -5
  38. package/src/components/datepicker.css +8 -8
  39. package/src/components/file-upload.css +9 -4
  40. package/src/components/input-group.css +2 -2
  41. package/src/components/input.css +2 -2
  42. package/src/components/menu.css +3 -3
  43. package/src/components/pagination.css +1 -1
  44. package/src/components/selection.css +2 -2
  45. package/src/components/skeleton.css +1 -1
  46. package/src/components/stepper.css +1 -1
  47. package/src/components/table.css +12 -7
  48. package/src/components/tabs.css +32 -1
  49. package/src/components/timepicker.css +3 -3
  50. package/src/components/toast.css +1 -1
  51. package/src/components/tree.css +1 -1
  52. package/tokens/tokens.json +57 -4
@@ -9,6 +9,12 @@ import { usePopover } from '../usePopover';
9
9
  // (outside-click + focusout) mirrors the already-approved FdyCombo.tsx pattern. Range mode is
10
10
  // out of scope (v1 = single date).
11
11
 
12
+ /** Which grid is on screen. The drill goes days -> months -> years and back down. */
13
+ type CalendarLevel = 'days' | 'months' | 'years';
14
+
15
+ /** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
16
+ export type FdyDatepickerMode = 'day' | 'month';
17
+
12
18
  interface DayCell {
13
19
  date: Date;
14
20
  iso: string;
@@ -40,8 +46,11 @@ interface MonthCell {
40
46
  }
41
47
 
42
48
  export interface FdyDatepickerProps {
49
+ /** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
43
50
  value: string | null;
44
51
  onChange: (value: string) => void;
52
+ /** `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and answers with `YYYY-MM`. */
53
+ mode?: FdyDatepickerMode;
45
54
  min?: string;
46
55
  max?: string;
47
56
  locale?: string;
@@ -79,6 +88,32 @@ function pad(n: number): string {
79
88
  function toISO(d: Date): string {
80
89
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
81
90
  }
91
+ function toISOMonth(d: Date): string {
92
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
93
+ }
94
+ /** Month mode reads BOTH shapes, and the dropped day is the point: a bound of `2026-03` lands on
95
+ * March 1st, which `isMonthDisabled` then compares a whole month against correctly at either end,
96
+ * so an app can pass the strings it already stores. A full date still works. */
97
+ function parseISOMonth(s: string | null | undefined): Date | null {
98
+ if (s === null || s === undefined || s === '') return null;
99
+ const parts: string[] = s.split('-');
100
+ if (parts.length < 2) return null;
101
+ const y: number = Number(parts[0]);
102
+ const m: number = Number(parts[1]);
103
+ const day: number = parts.length > 2 ? Number(parts[2]) : 1;
104
+ const d: Date = new Date(y, m - 1, day);
105
+ return Number.isNaN(d.getTime()) ? null : d;
106
+ }
107
+ /* The page's own `lang`, which is what COMPONENTS.md has promised all along and what the vanilla
108
+ enhancer has always done. Handing `undefined` to Intl instead falls back to the BROWSER's
109
+ language, so an Indonesian reader saw Indonesian month names inside a kit that is English on
110
+ every other path, and an Indonesian PAGE read by an English browser got English ones. That is
111
+ the symptom this month mode was asked for, so the fallback has to be the page. */
112
+ function pageLocale(): string {
113
+ if (typeof document === 'undefined') return 'en';
114
+ // `||`, not `??`: an empty `lang=""` is not null and Intl throws RangeError on it.
115
+ return document.documentElement.getAttribute('lang') || 'en';
116
+ }
82
117
  function parseISO(s: string | null | undefined): Date | null {
83
118
  if (s === null || s === undefined || s === '') return null;
84
119
  const parts: string[] = s.split('-');
@@ -131,10 +166,20 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
131
166
  // don't steal DOM focus off the button, matching the Vue template's behavior.
132
167
  const pendingFocusRef = useRef<string | null>(null);
133
168
 
169
+ const isMonthMode: boolean = props.mode === 'month';
170
+ /** Resolved once so every formatter below reads the same tag. */
171
+ const activeLocale: string = props.locale ?? pageLocale();
172
+ const parseValue = isMonthMode ? parseISOMonth : parseISO;
173
+
134
174
  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');
175
+ /* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
176
+ the title. Before this the only pointer route to another month was one click per month.
177
+ Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with.
178
+
179
+ Month mode has no level below months, so the day grid is unreachable by construction rather
180
+ than by every path remembering not to ask for it. */
181
+ const startLevel = (): CalendarLevel => (props.mode === 'month' ? 'months' : 'days');
182
+ const [level, setLevel] = useState<CalendarLevel>(startLevel);
138
183
  const [focusMonth, setFocusMonth] = useState<number>(0);
139
184
  const [focusYear, setFocusYear] = useState<number>(0);
140
185
 
@@ -157,13 +202,13 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
157
202
  panelRef.current?.setAttribute('popover', 'manual');
158
203
  }, []);
159
204
 
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]);
205
+ const selectedDate: Date | null = useMemo((): Date | null => parseValue(props.value), [props.value, isMonthMode]);
206
+ const minDate: Date | null = useMemo((): Date | null => parseValue(props.min), [props.min, isMonthMode]);
207
+ const maxDate: Date | null = useMemo((): Date | null => parseValue(props.max), [props.max, isMonthMode]);
163
208
  const isDisabled: boolean = props.disabled === true;
164
209
  const isReadonly: boolean = props.readonly === true;
165
210
  const isInvalid: boolean = props.invalid === true;
166
- const displayPlaceholder: string = props.placeholder ?? 'Select date';
211
+ const displayPlaceholder: string = props.placeholder ?? (isMonthMode ? 'Select month' : 'Select date');
167
212
  const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
168
213
  const chooseMonthLabelText: string = props.chooseMonthLabel ?? 'Choose month';
169
214
  const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
@@ -172,19 +217,19 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
172
217
  const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
173
218
  const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
174
219
  const monthCellFmt: Intl.DateTimeFormat = useMemo(
175
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
176
- [props.locale],
220
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'short' }),
221
+ [activeLocale],
177
222
  );
178
223
  const monthNameFmt: Intl.DateTimeFormat = useMemo(
179
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
180
- [props.locale],
224
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'long', year: 'numeric' }),
225
+ [activeLocale],
181
226
  );
182
227
  const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
183
228
  const nextMonthLabelText: string = props.nextMonthLabel ?? 'Next month';
184
229
  const clearLabelText: string = props.clearLabel ?? 'Clear date';
185
230
 
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());
231
+ const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseValue(props.value) ?? new Date()));
232
+ const [focusDate, setFocusDate] = useState<Date>((): Date => parseValue(props.value) ?? new Date());
188
233
 
189
234
  // Keep the roving-tabindex cell inside the visible month so the grid stays keyboard-reachable
190
235
  // after navigating with Home/End/PageUp/PageDown (mirrors freeday-datepicker.js's clamp).
@@ -195,23 +240,28 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
195
240
  }, [focusDate, viewMonth]);
196
241
 
197
242
  const monthFmt: Intl.DateTimeFormat = useMemo(
198
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
199
- [props.locale],
243
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'long', year: 'numeric' }),
244
+ [activeLocale],
200
245
  );
201
246
  const valueFmt: Intl.DateTimeFormat = useMemo(
202
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { day: '2-digit', month: 'short', year: 'numeric' }),
203
- [props.locale],
247
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { day: '2-digit', month: 'short', year: 'numeric' }),
248
+ [activeLocale],
204
249
  );
205
250
  const dayLabelFmt: Intl.DateTimeFormat = useMemo(
206
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
207
- [props.locale],
251
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
252
+ [activeLocale],
208
253
  );
209
254
  const dowFmt: Intl.DateTimeFormat = useMemo(
210
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'short' }),
211
- [props.locale],
255
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'short' }),
256
+ [activeLocale],
212
257
  );
213
258
 
214
- const displayValue: string = selectedDate !== null ? valueFmt.format(selectedDate) : displayPlaceholder;
259
+ /* Month mode shows the month through the SAME formatter the month cells and the day grid's title
260
+ already use, which is the whole point of the mode: <input type="month"> names the month in the
261
+ browser's language, this names it in the page's. */
262
+ const displayValue: string = selectedDate === null
263
+ ? displayPlaceholder
264
+ : (isMonthMode ? monthNameFmt : valueFmt).format(selectedDate);
215
265
 
216
266
  const weekdayHeaders: string[] = useMemo((): string[] => {
217
267
  const monday: Date = new Date(2024, 0, 1); // a known Monday
@@ -237,7 +287,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
237
287
  const monthCells: MonthCell[] = useMemo((): MonthCell[] => {
238
288
  const year: number = viewMonth.getFullYear();
239
289
  const today: Date = new Date();
240
- const sel: Date | null = parseISO(props.value);
290
+ const sel: Date | null = parseValue(props.value);
241
291
  return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
242
292
  const cellDate: Date = new Date(year, index, 1);
243
293
  return {
@@ -254,6 +304,10 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
254
304
  }, [viewMonth, props.value, focusMonth, monthCellFmt, monthNameFmt, minDate, maxDate]);
255
305
 
256
306
  const gridCells: DayCell[] = useMemo((): DayCell[] => {
307
+ /* Month mode never mounts this grid, and useMemo runs whether or not its result is read, so
308
+ without this every arrow key in the month grid would format forty two day labels through Intl
309
+ for cells nobody sees. Vue needs no such guard: a computed is lazy. */
310
+ if (isMonthMode) return [];
257
311
  const first: Date = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
258
312
  const offset: number = (first.getDay() + 6) % 7; // Monday-first
259
313
  const startCell: Date = addDays(first, -offset);
@@ -278,7 +332,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
278
332
  // Deps intentionally mirror the port recipe, not every closure read: minDate/maxDate,
279
333
  // dayLabelFmt and effectiveFocusDate all derive deterministically from the listed deps
280
334
  // within the same render pass, so this stays in sync without listing them separately.
281
- }, [viewMonth, props.value, props.min, props.max, props.locale]);
335
+ }, [viewMonth, props.value, props.min, props.max, activeLocale, isMonthMode]);
282
336
 
283
337
  // Focus the roving-tabindex cell after a render triggered by moveFocus/openPanel (React's
284
338
  // analogue of Vue's `nextTick(focusCell)`). Runs after every render; cheap no-op otherwise.
@@ -305,10 +359,27 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
305
359
 
306
360
  function openMonthGrid(): void {
307
361
  setFocusMonth(viewMonth.getMonth());
308
- setMode('months');
362
+ setLevel('months');
309
363
  pendingMonthFocusRef.current = viewMonth.getMonth();
310
364
  }
311
365
 
366
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
367
+ day mode a month is a step on the way down; in month mode it is the answer. */
368
+ function chooseMonth(index: number): void {
369
+ if (isMonthMode) pickMonth(index);
370
+ else openMonth(index);
371
+ }
372
+
373
+ function pickMonth(index: number): void {
374
+ const year: number = viewMonth.getFullYear();
375
+ if (isMonthDisabled(year, index)) return;
376
+ const first: Date = new Date(year, index, 1);
377
+ setFocusMonth(index);
378
+ setFocusDate(first);
379
+ props.onChange(toISOMonth(first));
380
+ closePanel(true);
381
+ }
382
+
312
383
  /* Picking a month is NAVIGATION, not selection: back to the day grid with the roving cell clamped
313
384
  into the new month, so nothing is committed until a day is chosen. */
314
385
  function openMonth(index: number): void {
@@ -317,7 +388,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
317
388
  const next: Date = new Date(year, index, Math.min(focusDate.getDate(), daysInMonth));
318
389
  setViewMonth(new Date(year, index, 1));
319
390
  setFocusDate(next);
320
- setMode('days');
391
+ setLevel('days');
321
392
  pendingFocusRef.current = toISO(next);
322
393
  }
323
394
 
@@ -341,7 +412,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
341
412
  const yearCells: YearCell[] = useMemo((): YearCell[] => {
342
413
  const start: number = yearPageStart(focusYear);
343
414
  const thisYear: number = new Date().getFullYear();
344
- const sel: Date | null = parseISO(props.value);
415
+ const sel: Date | null = parseValue(props.value);
345
416
  return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
346
417
  const year: number = start + index;
347
418
  return {
@@ -357,7 +428,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
357
428
 
358
429
  function openYearGrid(): void {
359
430
  setFocusYear(viewMonth.getFullYear());
360
- setMode('years');
431
+ setLevel('years');
361
432
  pendingYearFocusRef.current = viewMonth.getFullYear();
362
433
  }
363
434
 
@@ -366,7 +437,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
366
437
  function openYear(year: number): void {
367
438
  setViewMonth(new Date(year, viewMonth.getMonth(), 1));
368
439
  setFocusMonth(viewMonth.getMonth());
369
- setMode('months');
440
+ setLevel('months');
370
441
  pendingMonthFocusRef.current = viewMonth.getMonth();
371
442
  }
372
443
 
@@ -407,7 +478,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
407
478
  case 'PageUp': moveMonthFocus(focusMonth, -1); break;
408
479
  case 'PageDown': moveMonthFocus(focusMonth, 1); break;
409
480
  case 'Enter':
410
- case ' ': openMonth(focusMonth); break;
481
+ case ' ': chooseMonth(focusMonth); break;
411
482
  case 'Escape': closePanel(true); break;
412
483
  default: handled = false;
413
484
  }
@@ -441,7 +512,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
441
512
  const next: Date = selectedDate ?? focusDate ?? new Date();
442
513
  setFocusDate(next);
443
514
  setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
515
+ /* Reopening starts at the mode's own floor. Without this a month picker that drilled to the
516
+ year grid and was dismissed would open on years the next time, and a keyboard user would
517
+ land on a grid that cannot answer. */
518
+ setLevel(startLevel());
444
519
  setOpen(true);
520
+ if (isMonthMode) {
521
+ setFocusMonth(next.getMonth());
522
+ pendingMonthFocusRef.current = next.getMonth();
523
+ return;
524
+ }
445
525
  pendingFocusRef.current = toISO(next);
446
526
  }
447
527
 
@@ -575,10 +655,10 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
575
655
  <button
576
656
  type="button"
577
657
  className="fdy-cal__nav"
578
- aria-label={mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText}
658
+ aria-label={level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText}
579
659
  onClick={(): void => {
580
- if (mode === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
581
- else if (mode === 'months') moveMonthFocus(focusMonth, -1);
660
+ if (level === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
661
+ else if (level === 'months') moveMonthFocus(focusMonth, -1);
582
662
  else setViewMonth(addMonths(viewMonth, -1));
583
663
  }}
584
664
  >
@@ -588,25 +668,25 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
588
668
  id={titleId}
589
669
  type="button"
590
670
  className="fdy-cal__title"
591
- aria-label={mode === 'years' ? undefined : mode === 'months' ? chooseYearLabelText : chooseMonthLabelText}
592
- onClick={(): void => { if (mode === 'months') openYearGrid(); else openMonthGrid(); }}
671
+ aria-label={level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText}
672
+ onClick={(): void => { if (level === 'months') openYearGrid(); else openMonthGrid(); }}
593
673
  >
594
- {mode === 'years' ? yearRangeLabel : mode === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
674
+ {level === 'years' ? yearRangeLabel : level === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
595
675
  </button>
596
676
  <button
597
677
  type="button"
598
678
  className="fdy-cal__nav"
599
- aria-label={mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText}
679
+ aria-label={level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText}
600
680
  onClick={(): void => {
601
- if (mode === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
602
- else if (mode === 'months') moveMonthFocus(focusMonth, 1);
681
+ if (level === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
682
+ else if (level === 'months') moveMonthFocus(focusMonth, 1);
603
683
  else setViewMonth(addMonths(viewMonth, 1));
604
684
  }}
605
685
  >
606
686
  &rsaquo;
607
687
  </button>
608
688
  </div>
609
- {mode === 'years' ? (
689
+ {level === 'years' ? (
610
690
  <div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
611
691
  {yearCells.map((cell: YearCell) => (
612
692
  <button
@@ -625,7 +705,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
625
705
  </button>
626
706
  ))}
627
707
  </div>
628
- ) : mode === 'months' ? (
708
+ ) : level === 'months' ? (
629
709
  <div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
630
710
  {monthCells.map((cell: MonthCell) => (
631
711
  <button
@@ -638,7 +718,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
638
718
  aria-selected={cell.selected ? true : undefined}
639
719
  disabled={cell.disabled}
640
720
  tabIndex={cell.focusable ? 0 : -1}
641
- onClick={(): void => openMonth(cell.index)}
721
+ onClick={(): void => chooseMonth(cell.index)}
642
722
  >
643
723
  {cell.label}
644
724
  </button>
@@ -25,6 +25,28 @@ export interface FdyTableFooterProps {
25
25
  * the row you were looking at.
26
26
  */
27
27
  pageSizes?: readonly number[];
28
+ /* The strings, six of them, one per thing this band says. The raw path has had a hatch since
29
+ * 1.39.0 (`data-fdy-text-<key>` read through `textOf()`) and this path had none, so an app that
30
+ * does not speak English could only rewrite the whole band by hand; one shipped eighty one lines
31
+ * of its own markup to change five words. Names follow the enhancer's `TEXT` keys where a key
32
+ * exists, `info`, `prev`, `next`, so one string is not two names across the two paths. The other
33
+ * three have no twin because on the raw path that markup is the app's own. Defaults stay English
34
+ * on every path, which COMPONENTS.md promises and the suite enforces. */
35
+ /**
36
+ * The range line. `{from}`, `{to}` and `{total}` are substituted, every occurrence, as `textOf()`
37
+ * does. Default `Showing {from}–{to} of {total}`, the enhancer's `info`.
38
+ */
39
+ infoText?: string;
40
+ /** The visible word beside the rows-per-page control, which also names it. Default `Rows`. */
41
+ pageSizeLabel?: string;
42
+ /** Accessible name of the pager landmark. Default `Pagination`. */
43
+ paginationLabel?: string;
44
+ /** Accessible name of the previous-page arrow. Default `Previous page`. */
45
+ prevLabel?: string;
46
+ /** Accessible name of the next-page arrow. Default `Next page`. */
47
+ nextLabel?: string;
48
+ /** Accessible name of a numbered page button. `{n}` substituted. Default `Go to page {n}`. */
49
+ pageLabel?: string;
28
50
  }
29
51
 
30
52
  export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
@@ -39,6 +61,15 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
39
61
  const rangeTo: number = Math.min(total, (index + 1) * size);
40
62
  const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
41
63
 
64
+ /* Regex with /g, not String.replace on a string pattern, which stops at the first hit and would
65
+ * drop a second `{total}` a translator wrote. This is what `textOf()` does on the raw path. */
66
+ const infoLine: string = (props.infoText ?? 'Showing {from}–{to} of {total}')
67
+ .replace(/\{from\}/g, String(rangeFrom))
68
+ .replace(/\{to\}/g, String(rangeTo))
69
+ .replace(/\{total\}/g, String(total));
70
+ const pageAriaLabel = (page1: number): string =>
71
+ (props.pageLabel ?? 'Go to page {n}').replace(/\{n\}/g, String(page1));
72
+
42
73
  const hasPager: boolean = size > 0 && totalPages > 1;
43
74
  const sizes: readonly number[] = props.pageSizes ?? [];
44
75
  /* One page and no size control means there is nothing here to say, the table has always withheld
@@ -58,11 +89,11 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
58
89
 
59
90
  return (
60
91
  <div className="fdy-table-footer">
61
- <span className="fdy-table-footer__info">Showing {rangeFrom}–{rangeTo} of {total}</span>
92
+ <span className="fdy-table-footer__info">{infoLine}</span>
62
93
 
63
94
  {sizes.length > 0 && (
64
95
  <div className="fdy-table-footer__size">
65
- <span id={sizeLabelId}>Rows</span>
96
+ <span id={sizeLabelId}>{props.pageSizeLabel ?? 'Rows'}</span>
66
97
  {/* The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS
67
98
  it drops a dark panel into a light page. FdyCombo's own header says exactly that. */}
68
99
  <FdyCombo
@@ -75,10 +106,10 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
75
106
  )}
76
107
 
77
108
  {hasPager && (
78
- <nav className="fdy-pagination" aria-label="Pagination">
109
+ <nav className="fdy-pagination" aria-label={props.paginationLabel ?? 'Pagination'}>
79
110
  <ul className="fdy-pagination__list">
80
111
  <li>
81
- <button type="button" className="fdy-pagination__link" aria-label="Previous page" disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
112
+ <button type="button" className="fdy-pagination__link" aria-label={props.prevLabel ?? 'Previous page'} disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
82
113
  </li>
83
114
  {pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
84
115
  <li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
@@ -87,12 +118,12 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
87
118
  ) : p === currentPage1 ? (
88
119
  <span className="fdy-pagination__link" aria-current="page">{p}</span>
89
120
  ) : (
90
- <button type="button" className="fdy-pagination__link" aria-label={`Go to page ${p}`} onClick={(): void => goTo(p)}>{p}</button>
121
+ <button type="button" className="fdy-pagination__link" aria-label={pageAriaLabel(p)} onClick={(): void => goTo(p)}>{p}</button>
91
122
  )}
92
123
  </li>
93
124
  ))}
94
125
  <li>
95
- <button type="button" className="fdy-pagination__link" aria-label="Next page" disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
126
+ <button type="button" className="fdy-pagination__link" aria-label={props.nextLabel ?? 'Next page'} disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
96
127
  </li>
97
128
  </ul>
98
129
  </nav>
@@ -29,7 +29,7 @@ export interface FdyTableChangeDetail { page: number; sort: string | null; query
29
29
  // Re-exports of React components and hooks
30
30
  export { usePopover } from './usePopover';
31
31
  export { FdyCombo, type FdyComboProps, type FdyComboOption } from './components/FdyCombo';
32
- export { FdyDatepicker, type FdyDatepickerProps } from './components/FdyDatepicker';
32
+ export { FdyDatepicker, type FdyDatepickerProps, type FdyDatepickerMode } from './components/FdyDatepicker';
33
33
  export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
34
34
  export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
35
35
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';