@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
@@ -12,6 +12,12 @@ import { usePopover } from '../usePopover';
12
12
  // over the same kit classes. Open/close (outside-click + focusout) mirrors the
13
13
  // already-approved FdyCombo.vue pattern. Range mode is out of scope (v1 = single date).
14
14
 
15
+ /** Which grid is on screen. The drill goes days -> months -> years and back down. */
16
+ type CalendarLevel = 'days' | 'months' | 'years';
17
+
18
+ /** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
19
+ type FdyDatepickerMode = 'day' | 'month';
20
+
15
21
  interface DayCell {
16
22
  date: Date;
17
23
  iso: string;
@@ -25,7 +31,10 @@ interface DayCell {
25
31
  }
26
32
 
27
33
  const props = defineProps<{
34
+ /** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
28
35
  modelValue: string | null;
36
+ /** `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and answers with `YYYY-MM`. */
37
+ mode?: FdyDatepickerMode;
29
38
  min?: string;
30
39
  max?: string;
31
40
  locale?: string;
@@ -68,6 +77,9 @@ function pad(n: number): string {
68
77
  function toISO(d: Date): string {
69
78
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
70
79
  }
80
+ function toISOMonth(d: Date): string {
81
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
82
+ }
71
83
  function parseISO(s: string | null | undefined): Date | null {
72
84
  if (s === null || s === undefined || s === '') return null;
73
85
  const parts: string[] = s.split('-');
@@ -78,6 +90,29 @@ function parseISO(s: string | null | undefined): Date | null {
78
90
  const d: Date = new Date(y, m - 1, day);
79
91
  return Number.isNaN(d.getTime()) ? null : d;
80
92
  }
93
+ /** Month mode reads BOTH shapes, and the dropped day is the point: a bound of `2026-03` lands on
94
+ * March 1st, which `isMonthDisabled` then compares a whole month against correctly at either end,
95
+ * so an app can pass the strings it already stores. A full date still works. */
96
+ function parseISOMonth(s: string | null | undefined): Date | null {
97
+ if (s === null || s === undefined || s === '') return null;
98
+ const parts: string[] = s.split('-');
99
+ if (parts.length < 2) return null;
100
+ const y: number = Number(parts[0]);
101
+ const m: number = Number(parts[1]);
102
+ const day: number = parts.length > 2 ? Number(parts[2]) : 1;
103
+ const d: Date = new Date(y, m - 1, day);
104
+ return Number.isNaN(d.getTime()) ? null : d;
105
+ }
106
+ /* The page's own `lang`, which is what COMPONENTS.md has promised all along and what the vanilla
107
+ enhancer has always done. Handing `undefined` to Intl instead falls back to the BROWSER's
108
+ language, so an Indonesian reader saw Indonesian month names inside a kit that is English on
109
+ every other path, and an Indonesian PAGE read by an English browser got English ones. That is
110
+ the symptom this month mode was asked for, so the fallback has to be the page. */
111
+ function pageLocale(): string {
112
+ if (typeof document === 'undefined') return 'en';
113
+ // `||`, not `??`: an empty `lang=""` is not null and Intl throws RangeError on it.
114
+ return document.documentElement.getAttribute('lang') || 'en';
115
+ }
81
116
  function startOfDay(d: Date): Date {
82
117
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
83
118
  }
@@ -113,13 +148,22 @@ const open: Ref<boolean> = ref(false);
113
148
  // scroll container; positions against the trigger.
114
149
  usePopover(panelEl, triggerEl, open);
115
150
 
116
- const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseISO(props.modelValue));
117
- const minDate: ComputedRef<Date | null> = computed((): Date | null => parseISO(props.min));
118
- const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseISO(props.max));
151
+ const isMonthMode: ComputedRef<boolean> = computed((): boolean => props.mode === 'month');
152
+ /** Resolved once here so every formatter below reads the same tag. */
153
+ const activeLocale: ComputedRef<string> = computed((): string => props.locale ?? pageLocale());
154
+ /** Reading `props.mode` inside the computeds below is what keeps them in step with it. */
155
+ function parseValue(s: string | null | undefined): Date | null {
156
+ return props.mode === 'month' ? parseISOMonth(s) : parseISO(s);
157
+ }
158
+ const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.modelValue));
159
+ const minDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.min));
160
+ const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.max));
119
161
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
120
162
  const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
121
163
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
122
- const displayPlaceholder: ComputedRef<string> = computed((): string => props.placeholder ?? 'Select date');
164
+ const displayPlaceholder: ComputedRef<string> = computed(
165
+ (): string => props.placeholder ?? (isMonthMode.value ? 'Select month' : 'Select date'),
166
+ );
123
167
  const showClear: ComputedRef<boolean> = computed(
124
168
  (): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
125
169
  );
@@ -145,10 +189,18 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
145
189
  return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
146
190
  });
147
191
 
148
- /* 'days' | 'months', the calendar drills one level up instead of growing furniture beside the title.
149
- Before this the only pointer route to another month was one click per month: August 2026 to March
150
- 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all. */
151
- const mode: Ref<'days' | 'months' | 'years'> = ref('days');
192
+ /* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
193
+ the title. Before this the only pointer route to another month was one click per month: August
194
+ 2026 to March 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all.
195
+ Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with, and one
196
+ name for both would read as the same thing in a template that mentions them together.
197
+
198
+ Month mode has no level below months, so the day grid is unreachable by construction rather
199
+ than by every path remembering not to ask for it. */
200
+ function startLevel(): CalendarLevel {
201
+ return props.mode === 'month' ? 'months' : 'days';
202
+ }
203
+ const level: Ref<CalendarLevel> = ref(startLevel());
152
204
  const focusMonth: Ref<number> = ref(0);
153
205
  const focusYear: Ref<number> = ref(0);
154
206
 
@@ -159,10 +211,10 @@ const YEARS_PER_PAGE = 12;
159
211
  const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
160
212
 
161
213
  const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
162
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
214
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'short' }),
163
215
  );
164
216
  const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
165
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
217
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
166
218
  );
167
219
 
168
220
  interface MonthCell {
@@ -243,21 +295,26 @@ const yearRangeLabel: ComputedRef<string> = computed(
243
295
  );
244
296
 
245
297
  const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
246
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
298
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
247
299
  );
248
300
  const valueFmt: ComputedRef<Intl.DateTimeFormat> = computed(
249
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { day: '2-digit', month: 'short', year: 'numeric' }),
301
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { day: '2-digit', month: 'short', year: 'numeric' }),
250
302
  );
251
303
  const dayLabelFmt: ComputedRef<Intl.DateTimeFormat> = computed(
252
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
304
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
253
305
  );
254
306
  const dowFmt: ComputedRef<Intl.DateTimeFormat> = computed(
255
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'short' }),
307
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'short' }),
256
308
  );
257
309
 
258
- const displayValue: ComputedRef<string> = computed((): string =>
259
- selectedDate.value !== null ? valueFmt.value.format(selectedDate.value) : displayPlaceholder.value,
260
- );
310
+ /* Month mode shows the month through the SAME formatter the month cells and the day grid's title
311
+ already use, which is the whole point of the mode: <input type="month"> names the month in the
312
+ browser's language, this names it in the page's. */
313
+ const displayValue: ComputedRef<string> = computed((): string => {
314
+ if (selectedDate.value === null) return displayPlaceholder.value;
315
+ const fmt: Intl.DateTimeFormat = isMonthMode.value ? monthNameFmt.value : valueFmt.value;
316
+ return fmt.format(selectedDate.value);
317
+ });
261
318
 
262
319
  const weekdayHeaders: ComputedRef<string[]> = computed((): string[] => {
263
320
  const monday: Date = new Date(2024, 0, 1); // a known Monday
@@ -310,7 +367,7 @@ function moveFocus(next: Date): void {
310
367
 
311
368
  function openMonthGrid(): void {
312
369
  focusMonth.value = view.value.getMonth();
313
- mode.value = 'months';
370
+ level.value = 'months';
314
371
  void nextTick((): void => focusMonthCell());
315
372
  }
316
373
 
@@ -319,6 +376,25 @@ function focusMonthCell(): void {
319
376
  cell?.focus();
320
377
  }
321
378
 
379
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
380
+ day mode a month is a step on the way down; in month mode it is the answer. */
381
+ function chooseMonth(index: number): void {
382
+ if (isMonthMode.value) pickMonth(index);
383
+ else openMonth(index);
384
+ }
385
+
386
+ function pickMonth(index: number): void {
387
+ const year: number = view.value.getFullYear();
388
+ if (isMonthDisabled(year, index)) return;
389
+ const first: Date = new Date(year, index, 1);
390
+ focusMonth.value = index;
391
+ focusDate.value = first;
392
+ const iso: string = toISOMonth(first);
393
+ emit('update:modelValue', iso);
394
+ emit('change', iso);
395
+ closePanel(true);
396
+ }
397
+
322
398
  /* Picking a month is NAVIGATION, not selection: it drops back to the day grid with the roving cell
323
399
  clamped into the new month, so nothing is committed until a day is chosen. */
324
400
  function openMonth(index: number): void {
@@ -327,13 +403,13 @@ function openMonth(index: number): void {
327
403
  const day: number = Math.min(focusDate.value.getDate(), daysInMonth);
328
404
  view.value = new Date(year, index, 1);
329
405
  focusDate.value = new Date(year, index, day);
330
- mode.value = 'days';
406
+ level.value = 'days';
331
407
  void nextTick((): void => focusCell());
332
408
  }
333
409
 
334
410
  function openYearGrid(): void {
335
411
  focusYear.value = view.value.getFullYear();
336
- mode.value = 'years';
412
+ level.value = 'years';
337
413
  void nextTick((): void => focusYearCell());
338
414
  }
339
415
 
@@ -347,7 +423,7 @@ function focusYearCell(): void {
347
423
  function openYear(year: number): void {
348
424
  view.value = new Date(year, view.value.getMonth(), 1);
349
425
  focusMonth.value = view.value.getMonth();
350
- mode.value = 'months';
426
+ level.value = 'months';
351
427
  void nextTick((): void => focusMonthCell());
352
428
  }
353
429
 
@@ -394,7 +470,7 @@ function onMonthKeydown(e: KeyboardEvent): void {
394
470
  case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
395
471
  case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
396
472
  case 'Enter':
397
- case ' ': openMonth(focusMonth.value); break;
473
+ case ' ': chooseMonth(focusMonth.value); break;
398
474
  case 'Escape': closePanel(true); break;
399
475
  default: handled = false;
400
476
  }
@@ -423,7 +499,16 @@ function openPanel(): void {
423
499
  if (isDisabled.value || isReadonly.value || open.value) return;
424
500
  focusDate.value = selectedDate.value ?? focusDate.value ?? new Date();
425
501
  view.value = new Date(focusDate.value.getFullYear(), focusDate.value.getMonth(), 1);
502
+ /* Reopening starts at the mode's own floor. Without this a month picker that drilled to the year
503
+ grid and was dismissed would open on years the next time, and a keyboard user would land on a
504
+ grid that cannot answer. */
505
+ level.value = startLevel();
426
506
  open.value = true;
507
+ if (isMonthMode.value) {
508
+ focusMonth.value = view.value.getMonth();
509
+ void nextTick((): void => focusMonthCell());
510
+ return;
511
+ }
427
512
  void nextTick((): void => focusCell());
428
513
  }
429
514
 
@@ -563,11 +648,11 @@ onBeforeUnmount((): void => {
563
648
  <button
564
649
  type="button"
565
650
  class="fdy-cal__nav"
566
- :aria-label="mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText"
651
+ :aria-label="level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText"
567
652
  @click="
568
- mode === 'years'
653
+ level === 'years'
569
654
  ? moveYearFocus(focusYear - 12)
570
- : mode === 'months'
655
+ : level === 'months'
571
656
  ? moveMonthFocus(focusMonth, -1)
572
657
  : (view = addMonths(view, -1))
573
658
  "
@@ -576,23 +661,23 @@ onBeforeUnmount((): void => {
576
661
  :id="titleId"
577
662
  type="button"
578
663
  class="fdy-cal__title"
579
- :aria-label="mode === 'years' ? undefined : mode === 'months' ? chooseYearLabelText : chooseMonthLabelText"
580
- @click="mode === 'months' ? openYearGrid() : openMonthGrid()"
581
- >{{ mode === 'years' ? yearRangeLabel : mode === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
664
+ :aria-label="level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText"
665
+ @click="level === 'months' ? openYearGrid() : openMonthGrid()"
666
+ >{{ level === 'years' ? yearRangeLabel : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
582
667
  <button
583
668
  type="button"
584
669
  class="fdy-cal__nav"
585
- :aria-label="mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText"
670
+ :aria-label="level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText"
586
671
  @click="
587
- mode === 'years'
672
+ level === 'years'
588
673
  ? moveYearFocus(focusYear + 12)
589
- : mode === 'months'
674
+ : level === 'months'
590
675
  ? moveMonthFocus(focusMonth, 1)
591
676
  : (view = addMonths(view, 1))
592
677
  "
593
678
  >›</button>
594
679
  </div>
595
- <div v-if="mode === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
680
+ <div v-if="level === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
596
681
  <button
597
682
  v-for="cell in yearCells"
598
683
  :key="cell.year"
@@ -607,7 +692,7 @@ onBeforeUnmount((): void => {
607
692
  @click="openYear(cell.year)"
608
693
  >{{ cell.year }}</button>
609
694
  </div>
610
- <div v-else-if="mode === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
695
+ <div v-else-if="level === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
611
696
  <button
612
697
  v-for="cell in monthCells"
613
698
  :key="cell.index"
@@ -619,7 +704,7 @@ onBeforeUnmount((): void => {
619
704
  :aria-selected="cell.selected ? 'true' : undefined"
620
705
  :disabled="cell.disabled"
621
706
  :tabindex="cell.focusable ? 0 : -1"
622
- @click="openMonth(cell.index)"
707
+ @click="chooseMonth(cell.index)"
623
708
  >{{ cell.label }}</button>
624
709
  </div>
625
710
  <div v-else class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">
@@ -25,6 +25,28 @@ const props = defineProps<{
25
25
  * holds 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
  const emit = defineEmits<{ 'update:page': [page: FdyPageState] }>();
@@ -47,6 +69,13 @@ const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number
47
69
  pageWindow(currentPage1.value, totalPages.value),
48
70
  );
49
71
 
72
+ const infoLine: ComputedRef<string> = computed((): string =>
73
+ (props.infoText ?? 'Showing {from}–{to} of {total}')
74
+ .replace(/\{from\}/g, String(rangeFrom.value))
75
+ .replace(/\{to\}/g, String(rangeTo.value))
76
+ .replace(/\{total\}/g, String(props.page.total)),
77
+ );
78
+
50
79
  const hasPager: ComputedRef<boolean> = computed((): boolean => props.page.size > 0 && totalPages.value > 1);
51
80
  const sizes: ComputedRef<readonly number[]> = computed((): readonly number[] => props.pageSizes ?? []);
52
81
  /* Just the number. The control sits beside a label reading "Rows", so "20 per page" states the same
@@ -58,6 +87,10 @@ const sizeOptions: ComputedRef<Array<{ value: string; label: string }>> = comput
58
87
  * the whole band in that case, and this is where that decision now lives. */
59
88
  const visible: ComputedRef<boolean> = computed((): boolean => hasPager.value || sizes.value.length > 0);
60
89
 
90
+ function pageAriaLabel(page1: number): string {
91
+ return (props.pageLabel ?? 'Go to page {n}').replace(/\{n\}/g, String(page1));
92
+ }
93
+
61
94
  function goTo(page1: number): void {
62
95
  const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
63
96
  emit('update:page', { index: clamped - 1, size: props.page.size, total: props.page.total });
@@ -76,10 +109,10 @@ function onSize(value: string): void {
76
109
 
77
110
  <template>
78
111
  <div v-if="visible" class="fdy-table-footer">
79
- <span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ page.total }}</span>
112
+ <span class="fdy-table-footer__info">{{ infoLine }}</span>
80
113
 
81
114
  <div v-if="sizes.length > 0" class="fdy-table-footer__size">
82
- <span :id="sizeLabelId">Rows</span>
115
+ <span :id="sizeLabelId">{{ pageSizeLabel ?? 'Rows' }}</span>
83
116
  <!-- The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS it
84
117
  drops a dark panel into a light page. FdyCombo's own header says exactly that. -->
85
118
  <FdyCombo
@@ -90,13 +123,13 @@ function onSize(value: string): void {
90
123
  />
91
124
  </div>
92
125
 
93
- <nav v-if="hasPager" class="fdy-pagination" aria-label="Pagination">
126
+ <nav v-if="hasPager" class="fdy-pagination" :aria-label="paginationLabel ?? 'Pagination'">
94
127
  <ul class="fdy-pagination__list">
95
128
  <li>
96
129
  <button
97
130
  type="button"
98
131
  class="fdy-pagination__link"
99
- aria-label="Previous page"
132
+ :aria-label="prevLabel ?? 'Previous page'"
100
133
  :disabled="currentPage1 === 1"
101
134
  @click="goTo(currentPage1 - 1)"
102
135
  >‹</button>
@@ -104,13 +137,13 @@ function onSize(value: string): void {
104
137
  <li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
105
138
  <span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
106
139
  <span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
107
- <button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
140
+ <button v-else type="button" class="fdy-pagination__link" :aria-label="pageAriaLabel(p)" @click="goTo(p)">{{ p }}</button>
108
141
  </li>
109
142
  <li>
110
143
  <button
111
144
  type="button"
112
145
  class="fdy-pagination__link"
113
- aria-label="Next page"
146
+ :aria-label="nextLabel ?? 'Next page'"
114
147
  :disabled="currentPage1 === totalPages"
115
148
  @click="goTo(currentPage1 + 1)"
116
149
  >›</button>
@@ -11,6 +11,8 @@
11
11
  * <div data-fdy-datepicker data-role="to" data-placeholder="To"></div>
12
12
  * </div>
13
13
  * The range links the two: the end can never precede the start (out-of-range days disable).
14
+ * - Month: <div data-fdy-datepicker data-mode="month" data-value="2026-10"></div>
15
+ * The calendar opens ON the month grid and stops there; the value is "YYYY-MM".
14
16
  *
15
17
  * Emits a bubbling "fdy-datepicker-change" CustomEvent on the wrapper: detail {value, date}.
16
18
  * Keyboard in the grid: ←/→ ±1 day, ↑/↓ ±1 week, Home/End week edge, PageUp/Down ±month,
@@ -39,6 +41,11 @@
39
41
  var TEXT = {
40
42
  label: 'Date',
41
43
  placeholder: 'Choose a date',
44
+ /* The month mode's own pair. Reusing `label` and `placeholder` would name a month field "Date"
45
+ and ask the reader to "Choose a date" on a control that cannot express a day, so every caller
46
+ would have to pass both attributes to undo a default that was simply wrong for the mode. */
47
+ labelMonth: 'Month',
48
+ placeholderMonth: 'Choose a month',
42
49
  prevMonth: 'Previous month',
43
50
  nextMonth: 'Next month',
44
51
  prevYear: 'Previous year',
@@ -69,6 +76,7 @@
69
76
  function uid(p) { uidSeq += 1; return p + '-' + uidSeq; }
70
77
  function pad(n) { return n < 10 ? '0' + n : '' + n; }
71
78
  function toISO(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
79
+ function toISOMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1); }
72
80
  function parseISO(s) {
73
81
  if (!s) return null;
74
82
  var p = String(s).split('-');
@@ -76,6 +84,17 @@
76
84
  var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
77
85
  return isNaN(d.getTime()) ? null : d;
78
86
  }
87
+ /* Month mode reads BOTH shapes, and the day it drops is the point: a bound of "2026-03" lands on
88
+ March 1st, and monthDisabled then compares a whole month against it correctly at either end,
89
+ so an app can pass the same strings it stores. A full date still works for a host that keeps
90
+ its bounds in one format across a form. */
91
+ function parseISOMonth(s) {
92
+ if (!s) return null;
93
+ var p = String(s).split('-');
94
+ if (p.length < 2) return null;
95
+ var d = new Date(Number(p[0]), Number(p[1]) - 1, p.length > 2 ? Number(p[2]) : 1);
96
+ return isNaN(d.getTime()) ? null : d;
97
+ }
79
98
  function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
80
99
  function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
81
100
  function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
@@ -102,11 +121,16 @@
102
121
  wrap.dataset.fdyDpReady = '1';
103
122
  wrap.classList.add('fdy-datepicker');
104
123
 
105
- var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, 'placeholder');
106
- var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
107
- var selected = parseISO(wrap.getAttribute('data-value'));
108
- var minDate = parseISO(wrap.getAttribute('data-min'));
109
- var maxDate = parseISO(wrap.getAttribute('data-max'));
124
+ /* `data-mode="month"` makes the month grid the DESTINATION rather than a waypoint: the panel
125
+ opens on it, Enter and a click commit from it, the day grid is never built, and the value is
126
+ "YYYY-MM". Anything else, including the attribute's absence, is the day picker unchanged. */
127
+ var monthOnly = wrap.getAttribute('data-mode') === 'month';
128
+ var parseVal = monthOnly ? parseISOMonth : parseISO;
129
+ var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, monthOnly ? 'placeholderMonth' : 'placeholder');
130
+ var label = wrap.getAttribute('data-label') || textOf(wrap, monthOnly ? 'labelMonth' : 'label');
131
+ var selected = parseVal(wrap.getAttribute('data-value'));
132
+ var minDate = parseVal(wrap.getAttribute('data-min'));
133
+ var maxDate = parseVal(wrap.getAttribute('data-max'));
110
134
  var rangeStart = null, rangeEnd = null; // for in-range shading
111
135
  var view = startOfDay(selected || new Date());
112
136
  view = new Date(view.getFullYear(), view.getMonth(), 1);
@@ -116,7 +140,7 @@
116
140
  August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
117
141
  shortcut nobody can see is not an affordance, the repo's own test helper clicked "previous
118
142
  month" twenty-four times to reach a date two years back. */
119
- var mode = 'days';
143
+ var mode = monthOnly ? 'months' : 'days';
120
144
  var focusMonth = null;
121
145
  var focusYear = null;
122
146
 
@@ -152,7 +176,10 @@
152
176
 
153
177
  function updateDisplay() {
154
178
  if (selected) {
155
- valueSpan.textContent = valueFmt.format(selected);
179
+ /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
180
+ which is the whole reason this mode exists: <input type="month"> names the month in the
181
+ browser's language, this names it in the page's. */
182
+ valueSpan.textContent = (monthOnly ? monthNameFmt : valueFmt).format(selected);
156
183
  valueSpan.classList.remove('fdy-datepicker__value--placeholder');
157
184
  } else {
158
185
  valueSpan.textContent = placeholder;
@@ -196,6 +223,10 @@
196
223
  }
197
224
 
198
225
  function render() {
226
+ /* The floor of the drill, stated once here rather than at every caller that could lower it:
227
+ in month mode there is no level below months, so the day grid is unreachable by
228
+ construction instead of by every path remembering not to ask for it. */
229
+ if (monthOnly && mode === 'days') mode = 'months';
199
230
  if (mode === 'years') { renderYears(); return; }
200
231
  if (mode === 'months') { renderMonths(); return; }
201
232
  panel.innerHTML = '';
@@ -300,7 +331,7 @@
300
331
  }
301
332
  if (monthDisabled(year, m)) btn.disabled = true;
302
333
  btn.tabIndex = m === focusMonth ? 0 : -1;
303
- btn.addEventListener('click', (function (mm) { return function () { openMonth(mm); }; })(m));
334
+ btn.addEventListener('click', (function (mm) { return function () { chooseMonth(mm); }; })(m));
304
335
  grid.appendChild(btn);
305
336
  }
306
337
  grid.addEventListener('keydown', onMonthKey);
@@ -414,6 +445,28 @@
414
445
  if (cell) cell.focus();
415
446
  }
416
447
 
448
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike.
449
+ In day mode a month is a step on the way down; in month mode it is the answer. */
450
+ function chooseMonth(m) {
451
+ if (monthOnly) pickMonth(m);
452
+ else openMonth(m);
453
+ }
454
+
455
+ function pickMonth(m) {
456
+ var year = view.getFullYear();
457
+ if (monthDisabled(year, m)) return;
458
+ selected = new Date(year, m, 1);
459
+ focusMonth = m;
460
+ focusDate = selected;
461
+ updateDisplay();
462
+ /* The same event name, so a host already listening keeps listening, and `date` is still a
463
+ real Date (the first of the month) rather than a second shape to parse. */
464
+ wrap.dispatchEvent(new CustomEvent('fdy-datepicker-change', {
465
+ bubbles: true, detail: { value: toISOMonth(selected), date: new Date(selected) }
466
+ }));
467
+ close(true);
468
+ }
469
+
417
470
  /* Picking a month is navigation, not selection: it drops back to the day grid with the roving
418
471
  cell clamped into the new month, so nothing is committed until a DAY is chosen. */
419
472
  function openMonth(m) {
@@ -445,7 +498,7 @@
445
498
  case 'PageUp': moveMonthFocus(focusMonth, -1); break;
446
499
  case 'PageDown': moveMonthFocus(focusMonth, 1); break;
447
500
  case 'Enter':
448
- case ' ': openMonth(focusMonth); break;
501
+ case ' ': chooseMonth(focusMonth); break;
449
502
  case 'Escape': close(true); break;
450
503
  default: handled = false;
451
504
  }
@@ -539,14 +592,15 @@
539
592
  function open() {
540
593
  if (stateDisabled || stateReadonly) return;
541
594
  if (!panel.hidden) return;
542
- mode = 'days';
595
+ mode = monthOnly ? 'months' : 'days';
543
596
  focusDate = selected || focusDate || new Date();
544
597
  view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
598
+ if (monthOnly) focusMonth = view.getMonth();
545
599
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
546
600
  trigger.setAttribute('aria-expanded', 'true');
547
601
  trigger.classList.add('is-open');
548
602
  render();
549
- focusFocusDate();
603
+ if (monthOnly) focusMonthCell(); else focusFocusDate();
550
604
  document.addEventListener('click', onDocClick, true);
551
605
  }
552
606
 
@@ -572,10 +626,10 @@
572
626
  var api = {
573
627
  wrap: wrap,
574
628
  setState: setState,
575
- getValue: function () { return selected ? toISO(selected) : ''; },
629
+ getValue: function () { return selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : ''; },
576
630
  clear: function () { selected = null; updateDisplay(); if (!panel.hidden) render(); },
577
- setMin: function (iso) { minDate = parseISO(iso); if (!panel.hidden) render(); },
578
- setMax: function (iso) { maxDate = parseISO(iso); if (!panel.hidden) render(); },
631
+ setMin: function (iso) { minDate = parseVal(iso); if (!panel.hidden) render(); },
632
+ setMax: function (iso) { maxDate = parseVal(iso); if (!panel.hidden) render(); },
579
633
  setRange: function (fromIso, toIso) {
580
634
  rangeStart = parseISO(fromIso);
581
635
  rangeEnd = parseISO(toIso);
@@ -36,7 +36,21 @@
36
36
  its[(index + its.length) % its.length].focus();
37
37
  }
38
38
  var _pop = null;
39
- function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(menu, trigger, { matchWidth: false }); return _pop; }
39
+ /* `.fdy-menu--end` is the kit's existing spelling for "align this menu to the right", and the
40
+ stylesheet has always honoured it (left:auto;right:0). The popover path replaces that with
41
+ inline `fixed` coordinates, so without this line the class was silently dropped the moment
42
+ the menu went into the top layer: an account menu in a page gutter overshot its avatar and
43
+ ran to 4px off the screen edge. Reported from an app that patched it with a `translate`
44
+ hack keyed to the kit's own GAP. */
45
+ function popCtl() {
46
+ if (_pop === null && window.FreedayPopover) {
47
+ _pop = window.FreedayPopover.attach(menu, trigger, {
48
+ matchWidth: false,
49
+ align: menu.classList.contains('fdy-menu--end') ? 'end' : 'start'
50
+ });
51
+ }
52
+ return _pop;
53
+ }
40
54
  function open(index) {
41
55
  var p = popCtl(); if (p) p.show(); else menu.hidden = false;
42
56
  trigger.setAttribute('aria-expanded', 'true');