@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
@@ -3,24 +3,56 @@ import { useEffect, type RefObject } from 'react';
3
3
  // React port of adapters/vue/usePopover.ts. Lifts a dropdown panel into the top layer via the
4
4
  // native Popover API so it escapes any ancestor overflow clip (a .fdy-card, a scroll container),
5
5
  // then positions it `fixed` against its trigger (flip above when there's no room below; match the
6
- // trigger width). Degrades to the panel's own [hidden]/absolute CSS where Popover is unavailable.
6
+ // trigger width; line either edge up with the trigger's). Degrades to the panel's own
7
+ // [hidden]/absolute CSS where Popover is unavailable.
7
8
 
8
9
  interface PopoverElement extends HTMLElement {
9
10
  showPopover(): void;
10
11
  hidePopover(): void;
11
12
  }
12
13
 
14
+ /**
15
+ * Which of the panel's edges lines up with the trigger's. `'start'` (the default everywhere it is
16
+ * omitted) puts the panel's LEFT edge on the trigger's left edge, right for an input-shaped
17
+ * dropdown. `'end'` puts its RIGHT edge on the trigger's right edge, which is what a panel much
18
+ * wider than its trigger needs when the trigger sits in a right-hand gutter or a table header.
19
+ * Same two names as the raw path's `place()`, so one concept does not grow a second vocabulary.
20
+ */
21
+ export type FdyPopoverAlign = 'start' | 'end';
22
+
13
23
  const GAP = 4; // ~ --space-1
14
24
  const supported: boolean =
15
25
  typeof HTMLElement !== 'undefined'
16
26
  && typeof (HTMLElement.prototype as Partial<PopoverElement>).showPopover === 'function';
17
27
 
18
- function place(panel: HTMLElement, trigger: HTMLElement): void {
19
- const r: DOMRect = trigger.getBoundingClientRect();
28
+ function place(
29
+ panel: HTMLElement,
30
+ trigger: HTMLElement,
31
+ align: FdyPopoverAlign | undefined,
32
+ matchWidth: boolean | undefined,
33
+ ): void {
34
+ /* Take the panel out of the flow BEFORE measuring anything. Until this runs the panel is still
35
+ * `position:absolute` with `top:calc(100% + …)` from its component's stylesheet, an author rule
36
+ * outranks the popover UA style, and in the top layer that `100%` resolves against the viewport.
37
+ * So a panel that has just been shown sits one viewport height down the page and drags the
38
+ * document's scrollable overflow with it. Where scrollbars take layout space (every Windows and
39
+ * Linux browser; macOS overlay scrollbars hide it) the document gains a classic scrollbar for the
40
+ * duration, documentElement.clientWidth drops by its width, and the page relays out that much
41
+ * narrower. A trigger rect read in that instant belongs to a layout the next three lines undo:
42
+ * measured 741 instead of 756 for a combo in a 24px gutter, a scrollbar's width to the left. */
20
43
  panel.style.position = 'fixed';
21
44
  panel.style.margin = '0';
22
45
  panel.style.inset = 'auto';
23
- panel.style.minWidth = `${r.width}px`;
46
+ const r: DOMRect = trigger.getBoundingClientRect();
47
+ /* The trigger's width as a FLOOR, and `matchWidth !== false` rather than a truthiness test, the
48
+ same spelling the raw `place()` uses. It is what keeps the four input-shaped panels looking
49
+ like one control with its list hanging off it, and it is not cosmetic: `.fdy-combo__listbox`,
50
+ `.fdy-autocomplete__listbox` and `.fdy-cascade__panel` are `min-width:100%`, and once the panel
51
+ is `fixed` in the top layer that 100% is the VIEWPORT, so without this line a 220px combo opens
52
+ a 1000px listbox. A panel whose stylesheet asks for a width of its own opts out instead:
53
+ `.fdy-filter` wants `min-width:14rem` and hangs off a 24px funnel, and an inline floor of 24px
54
+ outranks the rule, which left the typed filter panel 206px wide against the 224 it asked for. */
55
+ if (matchWidth !== false) panel.style.minWidth = `${r.width}px`;
24
56
  const ph: number = panel.offsetHeight;
25
57
  const pw: number = panel.offsetWidth;
26
58
  const vw: number = document.documentElement.clientWidth;
@@ -28,16 +60,29 @@ function place(panel: HTMLElement, trigger: HTMLElement): void {
28
60
  const below: number = vh - r.bottom - GAP;
29
61
  const above: number = r.top - GAP;
30
62
  const top: number = ph <= below || below >= above ? r.bottom + GAP : Math.max(GAP, r.top - GAP - ph);
31
- let left: number = r.left;
32
- if (left + pw > vw - GAP) left = Math.max(GAP, vw - GAP - pw);
63
+ let left: number = align === 'end' ? r.right - pw : r.left;
64
+ /* Clamped on BOTH sides, and that is not belt-and-braces: the first line keeps a start-aligned
65
+ panel on screen, the second keeps an end-aligned one on screen when the panel is wider than the
66
+ room to the left of its trigger. Keeping only one of them trades an overflow on one edge for an
67
+ overflow on the other. */
68
+ if (left + pw > vw - GAP) left = vw - GAP - pw;
69
+ if (left < GAP) left = GAP;
33
70
  panel.style.top = `${Math.round(top)}px`;
34
71
  panel.style.left = `${Math.round(left)}px`;
35
72
  }
36
73
 
74
+ /**
75
+ * Anchor a dropdown `panelRef` to `triggerRef` in the top layer while `open` is true. The panel
76
+ * element must carry `popover="manual"`. `align` and `matchWidth` are plain values, not an options
77
+ * object, so the effect below can list them in its dependencies without a caller having to
78
+ * memoise anything. Omitting them means `'start'` and `true`.
79
+ */
37
80
  export function usePopover(
38
81
  panelRef: RefObject<HTMLElement | null>,
39
82
  triggerRef: RefObject<HTMLElement | null>,
40
83
  open: boolean,
84
+ align?: FdyPopoverAlign,
85
+ matchWidth?: boolean,
41
86
  ): void {
42
87
  useEffect((): void | (() => void) => {
43
88
  const panel: PopoverElement | null = panelRef.current as PopoverElement | null;
@@ -45,12 +90,12 @@ export function usePopover(
45
90
  if (!supported || panel === null || trigger === null) return;
46
91
 
47
92
  function reposition(): void {
48
- if (panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger);
93
+ if (panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger, align, matchWidth);
49
94
  }
50
95
 
51
96
  if (open) {
52
97
  if (!panel.matches(':popover-open')) panel.showPopover();
53
- place(panel, trigger);
98
+ place(panel, trigger, align, matchWidth);
54
99
  window.addEventListener('scroll', reposition, true);
55
100
  window.addEventListener('resize', reposition);
56
101
  return (): void => {
@@ -60,5 +105,5 @@ export function usePopover(
60
105
  };
61
106
  }
62
107
  return;
63
- }, [open, panelRef, triggerRef]);
108
+ }, [open, panelRef, triggerRef, align, matchWidth]);
64
109
  }
@@ -1,6 +1,31 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
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
  // A Vue-native binding over freeday's `.fdy-datepicker` / `.fdy-cal__*` classes
6
31
  // (see src/components/datepicker.css). freeday ships the styled trigger+popover
@@ -12,6 +37,12 @@ import { usePopover } from '../usePopover';
12
37
  // over the same kit classes. Open/close (outside-click + focusout) mirrors the
13
38
  // already-approved FdyCombo.vue pattern. Range mode is out of scope (v1 = single date).
14
39
 
40
+ /** Which grid is on screen. The drill goes days -> months -> years and back down. */
41
+ type CalendarLevel = 'days' | 'months' | 'years';
42
+
43
+ /** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
44
+ type FdyDatepickerMode = 'day' | 'month';
45
+
15
46
  interface DayCell {
16
47
  date: Date;
17
48
  iso: string;
@@ -25,7 +56,18 @@ interface DayCell {
25
56
  }
26
57
 
27
58
  const props = defineProps<{
59
+ /** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
28
60
  modelValue: string | null;
61
+ /**
62
+ * `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and
63
+ * answers with `YYYY-MM`.
64
+ *
65
+ * @deprecated Use `FdyMonthpicker` for a month. This prop makes `modelValue`, `min` and `max`
66
+ * carry two different formats while a *different* prop decides which, so the type cannot state
67
+ * what to store in them. `FdyMonthpicker` has one format per prop and no mode to read. Still
68
+ * works, unchanged, and removed in 4.0.
69
+ */
70
+ mode?: FdyDatepickerMode;
29
71
  min?: string;
30
72
  max?: string;
31
73
  locale?: string;
@@ -78,12 +120,6 @@ function parseISO(s: string | null | undefined): Date | null {
78
120
  const d: Date = new Date(y, m - 1, day);
79
121
  return Number.isNaN(d.getTime()) ? null : d;
80
122
  }
81
- function startOfDay(d: Date): Date {
82
- return new Date(d.getFullYear(), d.getMonth(), d.getDate());
83
- }
84
- function startOfMonth(d: Date): Date {
85
- return new Date(d.getFullYear(), d.getMonth(), 1);
86
- }
87
123
  function sameDay(a: Date | null, b: Date | null): boolean {
88
124
  return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
89
125
  }
@@ -92,11 +128,6 @@ function addDays(d: Date, n: number): Date {
92
128
  x.setDate(x.getDate() + n);
93
129
  return x;
94
130
  }
95
- function addMonths(d: Date, n: number): Date {
96
- const x: Date = new Date(d);
97
- x.setMonth(x.getMonth() + n);
98
- return x;
99
- }
100
131
 
101
132
  const baseId: string = useId();
102
133
  const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
@@ -113,13 +144,22 @@ const open: Ref<boolean> = ref(false);
113
144
  // scroll container; positions against the trigger.
114
145
  usePopover(panelEl, triggerEl, open);
115
146
 
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));
147
+ const isMonthMode: ComputedRef<boolean> = computed((): boolean => props.mode === 'month');
148
+ /** Resolved once here so every formatter below reads the same tag. */
149
+ const activeLocale: ComputedRef<string> = computed((): string => props.locale ?? pageLocale());
150
+ /** Reading `props.mode` inside the computeds below is what keeps them in step with it. */
151
+ function parseValue(s: string | null | undefined): Date | null {
152
+ return props.mode === 'month' ? parseISOMonth(s) : parseISO(s);
153
+ }
154
+ const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.modelValue));
155
+ const minDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.min));
156
+ const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.max));
119
157
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
120
158
  const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
121
159
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
122
- const displayPlaceholder: ComputedRef<string> = computed((): string => props.placeholder ?? 'Select date');
160
+ const displayPlaceholder: ComputedRef<string> = computed(
161
+ (): string => props.placeholder ?? (isMonthMode.value ? 'Select month' : 'Select date'),
162
+ );
123
163
  const showClear: ComputedRef<boolean> = computed(
124
164
  (): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
125
165
  );
@@ -145,119 +185,67 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
145
185
  return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
146
186
  });
147
187
 
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');
188
+ /* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
189
+ the title. Before this the only pointer route to another month was one click per month: August
190
+ 2026 to March 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all.
191
+ Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with, and one
192
+ name for both would read as the same thing in a template that mentions them together.
193
+
194
+ Month mode has no level below months, so the day grid is unreachable by construction rather
195
+ than by every path remembering not to ask for it. */
196
+ function startLevel(): CalendarLevel {
197
+ return props.mode === 'month' ? 'months' : 'days';
198
+ }
199
+ const level: Ref<CalendarLevel> = ref(startLevel());
152
200
  const focusMonth: Ref<number> = ref(0);
153
201
  const focusYear: Ref<number> = ref(0);
154
202
 
155
- /* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring the page on the
156
- year in view would make the arrows land on overlapping windows, and the same year would sit at a
157
- different spot every time you stepped. */
158
- const YEARS_PER_PAGE = 12;
159
- const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
160
-
161
- const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
162
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
163
- );
164
- const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
165
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
203
+ const formatters: ComputedRef<FdyMonthFormatters> = computed(
204
+ (): FdyMonthFormatters => monthFormatters(activeLocale.value),
166
205
  );
167
206
 
168
- interface MonthCell {
169
- index: number;
170
- label: string;
171
- ariaLabel: string;
172
- selected: boolean;
173
- today: boolean;
174
- disabled: boolean;
175
- focusable: boolean;
176
- }
177
-
178
- /** A month is only unreachable when the WHOLE month falls outside min/max. Checked against the
179
- * bounds directly, not by testing both ends with isDayDisabled: a range that sits INSIDE one month
180
- * disables both ends while the middle is perfectly selectable. */
181
- function isMonthDisabled(year: number, month: number): boolean {
182
- const first: Date = new Date(year, month, 1);
183
- const last: Date = new Date(year, month + 1, 0);
184
- if (minDate.value !== null && startOfDay(last).getTime() < startOfDay(minDate.value).getTime()) return true;
185
- if (maxDate.value !== null && startOfDay(first).getTime() > startOfDay(maxDate.value).getTime()) return true;
186
- return false;
187
- }
188
-
189
- const monthCells: ComputedRef<MonthCell[]> = computed((): MonthCell[] => {
190
- const year: number = view.value.getFullYear();
191
- const today: Date = new Date();
192
- const sel: Date | null = selectedDate.value;
193
- return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
194
- const cellDate: Date = new Date(year, index, 1);
195
- return {
196
- index,
197
- label: monthCellFmt.value.format(cellDate),
198
- ariaLabel: monthNameFmt.value.format(cellDate),
199
- selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
200
- today: today.getFullYear() === year && today.getMonth() === index,
201
- disabled: isMonthDisabled(year, index),
202
- focusable: index === focusMonth.value,
203
- };
204
- });
205
- });
206
-
207
- interface YearCell {
208
- year: number;
209
- selected: boolean;
210
- today: boolean;
211
- disabled: boolean;
212
- focusable: boolean;
213
- }
214
-
215
- /** A year is unreachable only when EVERY month in it falls outside min/max, the same rule
216
- * isMonthDisabled applies one level down, and for the same reason. */
217
- function isYearDisabled(year: number): boolean {
218
- if (minDate.value !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate.value).getTime()) return true;
219
- if (maxDate.value !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate.value).getTime()) return true;
220
- return false;
221
- }
207
+ const monthCells: ComputedRef<FdyMonthCell[]> = computed((): FdyMonthCell[] => buildMonthCells({
208
+ year: view.value.getFullYear(),
209
+ selected: selectedDate.value,
210
+ min: minDate.value,
211
+ max: maxDate.value,
212
+ focusMonth: focusMonth.value,
213
+ formatters: formatters.value,
214
+ }));
222
215
 
223
216
  const yearPage: ComputedRef<number> = computed((): number => yearPageStart(focusYear.value));
224
217
 
225
- const yearCells: ComputedRef<YearCell[]> = computed((): YearCell[] => {
226
- const start: number = yearPage.value;
227
- const thisYear: number = new Date().getFullYear();
228
- const sel: Date | null = selectedDate.value;
229
- return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
230
- const year: number = start + index;
231
- return {
232
- year,
233
- selected: sel !== null && sel.getFullYear() === year,
234
- today: thisYear === year,
235
- disabled: isYearDisabled(year),
236
- focusable: year === focusYear.value,
237
- };
238
- });
239
- });
218
+ const yearCells: ComputedRef<FdyYearCell[]> = computed((): FdyYearCell[] => buildYearCells({
219
+ pageStart: yearPage.value,
220
+ selected: selectedDate.value,
221
+ min: minDate.value,
222
+ max: maxDate.value,
223
+ focusYear: focusYear.value,
224
+ }));
240
225
 
241
- const yearRangeLabel: ComputedRef<string> = computed(
242
- (): string => `${yearPage.value} – ${yearPage.value + YEARS_PER_PAGE - 1}`,
243
- );
226
+ const yearRangeText: ComputedRef<string> = computed((): string => yearRangeLabel(yearPage.value));
244
227
 
245
228
  const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
246
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
229
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
247
230
  );
248
231
  const valueFmt: ComputedRef<Intl.DateTimeFormat> = computed(
249
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { day: '2-digit', month: 'short', year: 'numeric' }),
232
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { day: '2-digit', month: 'short', year: 'numeric' }),
250
233
  );
251
234
  const dayLabelFmt: ComputedRef<Intl.DateTimeFormat> = computed(
252
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
235
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
253
236
  );
254
237
  const dowFmt: ComputedRef<Intl.DateTimeFormat> = computed(
255
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'short' }),
238
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'short' }),
256
239
  );
257
240
 
258
- const displayValue: ComputedRef<string> = computed((): string =>
259
- selectedDate.value !== null ? valueFmt.value.format(selectedDate.value) : displayPlaceholder.value,
260
- );
241
+ /* Month mode shows the month through the SAME formatter the month cells and the day grid's title
242
+ already use, which is the whole point of the mode: <input type="month"> names the month in the
243
+ browser's language, this names it in the page's. */
244
+ const displayValue: ComputedRef<string> = computed((): string => {
245
+ if (selectedDate.value === null) return displayPlaceholder.value;
246
+ const fmt: Intl.DateTimeFormat = isMonthMode.value ? formatters.value.name : valueFmt.value;
247
+ return fmt.format(selectedDate.value);
248
+ });
261
249
 
262
250
  const weekdayHeaders: ComputedRef<string[]> = computed((): string[] => {
263
251
  const monday: Date = new Date(2024, 0, 1); // a known Monday
@@ -310,7 +298,7 @@ function moveFocus(next: Date): void {
310
298
 
311
299
  function openMonthGrid(): void {
312
300
  focusMonth.value = view.value.getMonth();
313
- mode.value = 'months';
301
+ level.value = 'months';
314
302
  void nextTick((): void => focusMonthCell());
315
303
  }
316
304
 
@@ -319,6 +307,25 @@ function focusMonthCell(): void {
319
307
  cell?.focus();
320
308
  }
321
309
 
310
+ /* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
311
+ day mode a month is a step on the way down; in month mode it is the answer. */
312
+ function chooseMonth(index: number): void {
313
+ if (isMonthMode.value) pickMonth(index);
314
+ else openMonth(index);
315
+ }
316
+
317
+ function pickMonth(index: number): void {
318
+ const year: number = view.value.getFullYear();
319
+ if (monthDisabled(year, index, minDate.value, maxDate.value)) return;
320
+ const first: Date = new Date(year, index, 1);
321
+ focusMonth.value = index;
322
+ focusDate.value = first;
323
+ const iso: string = toISOMonth(first);
324
+ emit('update:modelValue', iso);
325
+ emit('change', iso);
326
+ closePanel(true);
327
+ }
328
+
322
329
  /* Picking a month is NAVIGATION, not selection: it drops back to the day grid with the roving cell
323
330
  clamped into the new month, so nothing is committed until a day is chosen. */
324
331
  function openMonth(index: number): void {
@@ -327,13 +334,13 @@ function openMonth(index: number): void {
327
334
  const day: number = Math.min(focusDate.value.getDate(), daysInMonth);
328
335
  view.value = new Date(year, index, 1);
329
336
  focusDate.value = new Date(year, index, day);
330
- mode.value = 'days';
337
+ level.value = 'days';
331
338
  void nextTick((): void => focusCell());
332
339
  }
333
340
 
334
341
  function openYearGrid(): void {
335
342
  focusYear.value = view.value.getFullYear();
336
- mode.value = 'years';
343
+ level.value = 'years';
337
344
  void nextTick((): void => focusYearCell());
338
345
  }
339
346
 
@@ -347,7 +354,7 @@ function focusYearCell(): void {
347
354
  function openYear(year: number): void {
348
355
  view.value = new Date(year, view.value.getMonth(), 1);
349
356
  focusMonth.value = view.value.getMonth();
350
- mode.value = 'months';
357
+ level.value = 'months';
351
358
  void nextTick((): void => focusMonthCell());
352
359
  }
353
360
 
@@ -358,47 +365,29 @@ function moveYearFocus(year: number): void {
358
365
  }
359
366
 
360
367
  function onYearKeydown(e: KeyboardEvent): void {
361
- let handled: boolean = true;
362
- switch (e.key) {
363
- case 'ArrowLeft': moveYearFocus(focusYear.value - 1); break;
364
- case 'ArrowRight': moveYearFocus(focusYear.value + 1); break;
365
- case 'ArrowUp': moveYearFocus(focusYear.value - 3); break;
366
- case 'ArrowDown': moveYearFocus(focusYear.value + 3); break;
367
- case 'Home': moveYearFocus(yearPageStart(focusYear.value)); break;
368
- case 'End': moveYearFocus(yearPageStart(focusYear.value) + YEARS_PER_PAGE - 1); break;
369
- case 'PageUp': moveYearFocus(focusYear.value - YEARS_PER_PAGE); break;
370
- case 'PageDown': moveYearFocus(focusYear.value + YEARS_PER_PAGE); break;
371
- case 'Enter':
372
- case ' ': openYear(focusYear.value); break;
373
- case 'Escape': closePanel(true); break;
374
- default: handled = false;
375
- }
376
- if (handled) { e.preventDefault(); e.stopPropagation(); }
368
+ const intent = yearKeyIntent(e.key, focusYear.value);
369
+ if (intent.action === 'none') return;
370
+ e.preventDefault();
371
+ e.stopPropagation();
372
+ if (intent.action === 'focus') moveYearFocus(intent.year);
373
+ else if (intent.action === 'choose') openYear(intent.year);
374
+ else closePanel(true);
377
375
  }
378
376
 
377
+ /** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
379
378
  function moveMonthFocus(index: number, yearDelta: number): void {
380
379
  if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
381
- focusMonth.value = (index + 12) % 12;
380
+ focusMonth.value = index;
382
381
  void nextTick((): void => focusMonthCell());
383
382
  }
384
383
 
385
384
  function onMonthKeydown(e: KeyboardEvent): void {
386
- let handled: boolean = true;
387
- switch (e.key) {
388
- case 'ArrowLeft': moveMonthFocus(focusMonth.value - 1, 0); break;
389
- case 'ArrowRight': moveMonthFocus(focusMonth.value + 1, 0); break;
390
- case 'ArrowUp': moveMonthFocus(focusMonth.value - 3, 0); break;
391
- case 'ArrowDown': moveMonthFocus(focusMonth.value + 3, 0); break;
392
- case 'Home': moveMonthFocus(0, 0); break;
393
- case 'End': moveMonthFocus(11, 0); break;
394
- case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
395
- case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
396
- case 'Enter':
397
- case ' ': openMonth(focusMonth.value); break;
398
- case 'Escape': closePanel(true); break;
399
- default: handled = false;
400
- }
401
- if (handled) e.preventDefault();
385
+ const intent = monthKeyIntent(e.key, focusMonth.value);
386
+ if (intent.action === 'none') return;
387
+ e.preventDefault();
388
+ if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
389
+ else if (intent.action === 'choose') chooseMonth(intent.index);
390
+ else closePanel(true);
402
391
  }
403
392
 
404
393
  function pick(d: Date): void {
@@ -423,7 +412,16 @@ function openPanel(): void {
423
412
  if (isDisabled.value || isReadonly.value || open.value) return;
424
413
  focusDate.value = selectedDate.value ?? focusDate.value ?? new Date();
425
414
  view.value = new Date(focusDate.value.getFullYear(), focusDate.value.getMonth(), 1);
415
+ /* Reopening starts at the mode's own floor. Without this a month picker that drilled to the year
416
+ grid and was dismissed would open on years the next time, and a keyboard user would land on a
417
+ grid that cannot answer. */
418
+ level.value = startLevel();
426
419
  open.value = true;
420
+ if (isMonthMode.value) {
421
+ focusMonth.value = view.value.getMonth();
422
+ void nextTick((): void => focusMonthCell());
423
+ return;
424
+ }
427
425
  void nextTick((): void => focusCell());
428
426
  }
429
427
 
@@ -563,11 +561,11 @@ onBeforeUnmount((): void => {
563
561
  <button
564
562
  type="button"
565
563
  class="fdy-cal__nav"
566
- :aria-label="mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText"
564
+ :aria-label="level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText"
567
565
  @click="
568
- mode === 'years'
569
- ? moveYearFocus(focusYear - 12)
570
- : mode === 'months'
566
+ level === 'years'
567
+ ? moveYearFocus(focusYear - YEARS_PER_PAGE)
568
+ : level === 'months'
571
569
  ? moveMonthFocus(focusMonth, -1)
572
570
  : (view = addMonths(view, -1))
573
571
  "
@@ -576,23 +574,23 @@ onBeforeUnmount((): void => {
576
574
  :id="titleId"
577
575
  type="button"
578
576
  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>
577
+ :aria-label="level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText"
578
+ @click="level === 'months' ? openYearGrid() : openMonthGrid()"
579
+ >{{ level === 'years' ? yearRangeText : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
582
580
  <button
583
581
  type="button"
584
582
  class="fdy-cal__nav"
585
- :aria-label="mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText"
583
+ :aria-label="level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText"
586
584
  @click="
587
- mode === 'years'
588
- ? moveYearFocus(focusYear + 12)
589
- : mode === 'months'
585
+ level === 'years'
586
+ ? moveYearFocus(focusYear + YEARS_PER_PAGE)
587
+ : level === 'months'
590
588
  ? moveMonthFocus(focusMonth, 1)
591
589
  : (view = addMonths(view, 1))
592
590
  "
593
591
  >›</button>
594
592
  </div>
595
- <div v-if="mode === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
593
+ <div v-if="level === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
596
594
  <button
597
595
  v-for="cell in yearCells"
598
596
  :key="cell.year"
@@ -607,7 +605,7 @@ onBeforeUnmount((): void => {
607
605
  @click="openYear(cell.year)"
608
606
  >{{ cell.year }}</button>
609
607
  </div>
610
- <div v-else-if="mode === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
608
+ <div v-else-if="level === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
611
609
  <button
612
610
  v-for="cell in monthCells"
613
611
  :key="cell.index"
@@ -619,7 +617,7 @@ onBeforeUnmount((): void => {
619
617
  :aria-selected="cell.selected ? 'true' : undefined"
620
618
  :disabled="cell.disabled"
621
619
  :tabindex="cell.focusable ? 0 : -1"
622
- @click="openMonth(cell.index)"
620
+ @click="chooseMonth(cell.index)"
623
621
  >{{ cell.label }}</button>
624
622
  </div>
625
623
  <div v-else class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">