@cahyo-dimas/freeday 1.31.0 → 1.32.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.32.0] — 2026-08-18
7
+ Improvement note 004, both halves: a calendar you could not steer, and a check mark that talked.
8
+ ### Added
9
+ - **The calendar title is a control.** It was a `<div>` — the only thing naming the month, and not
10
+ clickable — so the only pointer route to another month was one click per month: August 2026 to
11
+ March 2022 is **53 clicks**. `Shift`+`PageUp` already jumped a year (APG, correct) but had no
12
+ affordance whatsoever; the reporting repo's own Playwright helper clicked "previous month"
13
+ **twenty-four times** and shipped, which is the strongest possible evidence that nobody finds a
14
+ shortcut nothing mentions.
15
+ Pressing the title now drills to a 12-cell month grid where the arrows step **years**. Same
16
+ journey: **7 clicks**. Picking a month is navigation, not selection — the day grid comes back with
17
+ the roving cell clamped into the new month and nothing is committed until a day is chosen.
18
+ Shipped in the vanilla enhancer, Vue and React; `FdyDateRange` composes the picker in both
19
+ adapters and the Blazor `FdyDatepicker` wraps the enhancer, so all of them inherit it.
20
+ New classes: `.fdy-cal__grid--months`, `.fdy-cal__month`. The month grid mirrors the day grid's
21
+ keyboard contract (arrows ±1/±3, Home/End, PageUp/PageDown a year, Enter/Space to choose), and
22
+ `COMPONENTS.md` now states the `Shift`+`PageUp` shortcut it never mentioned.
23
+ ### Fixed
24
+ - **A combo option's accessible name no longer changes when it is selected.** The tick lived inside
25
+ `role="option"` as text, so the selected option announced `✓August` while every other announced
26
+ `August`: the state was read twice — once as `aria-selected`, once as decoration — and
27
+ `getByRole('option', { name: 'August' })` stopped matching the one option that was selected. The
28
+ glyph is now painted by CSS with alt text (`content:"✓" / ""`), the same technique
29
+ `.fdy-label--required` uses. The conditional disappears from all four stacks, and the vanilla
30
+ enhancer's `optionLabel` loses the sibling-walk it needed to skip a glyph.
31
+ ### Notes on the shape of the fix
32
+ - **A bug written and caught inside this change:** swapping the calendar head with `v-if`/`v-else`
33
+ destroys the very button the user just pressed, focus falls to `<body>`, and the panel's own
34
+ `focusout` handler closes it mid-navigation. The head is now one element set whose labels and
35
+ handlers switch, and `focusout` ignores a **null** `relatedTarget` — focus lost because an element
36
+ was removed is not focus leaving the control, and a pointer that really lands outside is handled
37
+ by the mousedown path.
38
+ - The report's `aria-hidden` suggestion would also have worked; CSS alt text was chosen because it
39
+ removes the conditional from four implementations instead of adding an attribute to each.
40
+ ### Added — guards
41
+ - `browser/harness.mjs` gains **`axName(selector)`** — the accessible name as the *engine* computes
42
+ it (CDP `Accessibility.getPartialAXTree`). This mattered immediately: the first version of the
43
+ name invariant read `textContent`, which never contains CSS generated content, so it passed with
44
+ the tick back in the accessibility tree. With `axName`, that mutation reports `"✓ Badge"`.
45
+ - Real-click drill specs for the vanilla enhancer, Vue and React: the title is a `<button>`, drilling
46
+ shows 12 months and moves focus into the grid, the arrows step years, and picking a month commits
47
+ nothing.
48
+
6
49
  ## [1.31.0] — 2026-08-18
7
50
  Improvement note 002: density was a one-way door.
8
51
  ### Added
package/COMPONENTS.md CHANGED
@@ -403,12 +403,18 @@ Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
403
403
  </button>
404
404
  <ul class="fdy-combo__listbox" role="listbox" aria-labelledby="st-l" hidden>
405
405
  <li class="fdy-combo__option" role="option" data-value="paid" aria-selected="false"><span class="fdy-combo__check"></span>Paid</li>
406
- <li class="fdy-combo__option" role="option" data-value="pending" aria-selected="true"><span class="fdy-combo__check">✓</span>Pending</li>
406
+ <li class="fdy-combo__option" role="option" data-value="pending" aria-selected="true"><span class="fdy-combo__check"></span>Pending</li>
407
407
  </ul>
408
408
  </div>
409
409
  </div>
410
410
  ```
411
411
 
412
+ **The selected tick is CSS.** `.fdy-combo__check` is an empty box in the markup; the glyph is painted
413
+ by the stylesheet on `[aria-selected="true"]` with alt text, so it never enters the accessibility
414
+ tree. That keeps an option's accessible name **identical whether or not it is selected** — the state
415
+ is carried by `aria-selected` alone, announced once, and `getByRole('option', { name: 'August' })`
416
+ keeps matching after selection. Do not put a glyph in that span.
417
+
412
418
  ## Autocomplete — `.fdy-autocomplete`
413
419
  > **Typed wrapper: `<FdyAutocomplete>`** — Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
414
420
 
@@ -505,10 +511,20 @@ Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author
505
511
  — the enhancer builds everything.
506
512
 
507
513
  - `.fdy-datepicker` (+`--error`) · `__trigger` `__value` (+`--placeholder`) `__icon` `__clear`
508
- `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` `__dow` `__day`
514
+ `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` (+`--months`) `__dow`
515
+ `__day` `__month`
509
516
  - Range: wrap two pickers in `.fdy-daterange` + `data-fdy-daterange` (`role="group"`), children
510
517
  get `data-role="from"` / `"to"`; separator `.fdy-daterange__sep`. The end can't precede the start.
511
518
  **Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor) — use it in those stacks.
519
+ **Getting to a distant month.** The title is a **button**: press it and the day grid becomes a
520
+ 12-cell month grid for the current year, where the arrows step **years** instead of months. Pick a
521
+ month and you are back on days — choosing a month is navigation, so nothing is committed until a day
522
+ is selected. From August 2026 to March 2022 that is 7 clicks; walking month by month is 53.
523
+
524
+ Keyboard, in the day grid: arrows move a day, `Home`/`End` the week, `PageUp`/`PageDown` a month,
525
+ and **`Shift`+`PageUp`/`PageDown` a year** (WAI-ARIA APG). The month grid mirrors it: arrows ±1/±3
526
+ months, `Home`/`End` January/December, `PageUp`/`PageDown` a year, `Enter`/`Space` to choose.
527
+
512
528
  - Attributes: `data-value="YYYY-MM-DD"`, `data-placeholder`, `data-label`, `data-fdy-no-icon`
513
529
 
514
530
  ```html
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.31.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.31.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.32.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.31.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.31.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.32.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.32.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -21,7 +21,7 @@
21
21
  bool selected = Equals(opt.Value, Value);
22
22
  <li class="fdy-combo__option" role="option" data-value="@KeyOf(opt.Value)"
23
23
  aria-selected="@(selected ? "true" : "false")">
24
- <span class="fdy-combo__check">@(selected ? "✓" : "")</span>@opt.Label
24
+ <span class="fdy-combo__check"></span>@opt.Label
25
25
  </li>
26
26
  }
27
27
  </ul>
@@ -165,7 +165,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
165
165
  onClick={(): void => choose(i)}
166
166
  onMouseMove={(): void => setHighlight(i)}
167
167
  >
168
- <span className="fdy-combo__check">{opt.value === props.value ? '✓' : ''}</span>{opt.label}
168
+ <span className="fdy-combo__check" />{opt.label}
169
169
  </li>
170
170
  ))}
171
171
  </ul>
@@ -21,6 +21,16 @@ interface DayCell {
21
21
  focusable: boolean;
22
22
  }
23
23
 
24
+ interface MonthCell {
25
+ index: number;
26
+ label: string;
27
+ ariaLabel: string;
28
+ selected: boolean;
29
+ today: boolean;
30
+ disabled: boolean;
31
+ focusable: boolean;
32
+ }
33
+
24
34
  export interface FdyDatepickerProps {
25
35
  value: string | null;
26
36
  onChange: (value: string) => void;
@@ -38,6 +48,11 @@ export interface FdyDatepickerProps {
38
48
  /** Show a clear (×) button in the trigger when a date is set, so an optional date can be unset. Calls onChange('') to reset. Off by default. */
39
49
  clearable?: boolean;
40
50
  /** aria-label for the previous-month nav button. Default 'Previous month' — override for non-English UIs (month/weekday names already follow `locale`). */
51
+ /** aria-label for the title button that drills to the month grid. Default 'Choose month'. */
52
+ chooseMonthLabel?: string;
53
+ /** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
54
+ prevYearLabel?: string;
55
+ nextYearLabel?: string;
41
56
  prevMonthLabel?: string;
42
57
  /** aria-label for the next-month nav button. Default 'Next month'. */
43
58
  nextMonthLabel?: string;
@@ -104,6 +119,12 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
104
119
  const pendingFocusRef = useRef<string | null>(null);
105
120
 
106
121
  const [open, setOpen] = useState<boolean>(false);
122
+ /* 'days' | 'months' — the calendar drills one level up instead of growing furniture beside the
123
+ title. Before this the only pointer route to another month was one click per month. */
124
+ const [mode, setMode] = useState<'days' | 'months'>('days');
125
+ const [focusMonth, setFocusMonth] = useState<number>(0);
126
+ const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
127
+ const pendingMonthFocusRef = useRef<number | null>(null);
107
128
 
108
129
  // Render the calendar panel in the top layer (popover) so it never gets clipped by a card or
109
130
  // scroll container; positions against the trigger.
@@ -122,6 +143,17 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
122
143
  const isInvalid: boolean = props.invalid === true;
123
144
  const displayPlaceholder: string = props.placeholder ?? 'Select date';
124
145
  const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
146
+ const chooseMonthLabelText: string = props.chooseMonthLabel ?? 'Choose month';
147
+ const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
148
+ const nextYearLabelText: string = props.nextYearLabel ?? 'Next year';
149
+ const monthCellFmt: Intl.DateTimeFormat = useMemo(
150
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
151
+ [props.locale],
152
+ );
153
+ const monthNameFmt: Intl.DateTimeFormat = useMemo(
154
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
155
+ [props.locale],
156
+ );
125
157
  const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
126
158
  const nextMonthLabelText: string = props.nextMonthLabel ?? 'Next month';
127
159
  const clearLabelText: string = props.clearLabel ?? 'Clear date';
@@ -167,6 +199,35 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
167
199
  return false;
168
200
  }
169
201
 
202
+ /** A month is only unreachable when the WHOLE month falls outside min/max — a range that sits
203
+ * inside one month disables both ends while the middle is perfectly selectable. */
204
+ function isMonthDisabled(year: number, month: number): boolean {
205
+ const first: Date = new Date(year, month, 1);
206
+ const last: Date = new Date(year, month + 1, 0);
207
+ if (minDate !== null && startOfDay(last).getTime() < startOfDay(minDate).getTime()) return true;
208
+ if (maxDate !== null && startOfDay(first).getTime() > startOfDay(maxDate).getTime()) return true;
209
+ return false;
210
+ }
211
+
212
+ const monthCells: MonthCell[] = useMemo((): MonthCell[] => {
213
+ const year: number = viewMonth.getFullYear();
214
+ const today: Date = new Date();
215
+ const sel: Date | null = parseISO(props.value);
216
+ return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
217
+ const cellDate: Date = new Date(year, index, 1);
218
+ return {
219
+ index,
220
+ label: monthCellFmt.format(cellDate),
221
+ ariaLabel: monthNameFmt.format(cellDate),
222
+ selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
223
+ today: today.getFullYear() === year && today.getMonth() === index,
224
+ disabled: isMonthDisabled(year, index),
225
+ focusable: index === focusMonth,
226
+ };
227
+ });
228
+ // eslint-disable-next-line react-hooks/exhaustive-deps
229
+ }, [viewMonth, props.value, focusMonth, monthCellFmt, monthNameFmt, minDate, maxDate]);
230
+
170
231
  const gridCells: DayCell[] = useMemo((): DayCell[] => {
171
232
  const first: Date = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
172
233
  const offset: number = (first.getDay() + 6) % 7; // Monday-first
@@ -203,6 +264,57 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
203
264
  dayRefs.current[iso]?.focus();
204
265
  });
205
266
 
267
+ useEffect((): void => {
268
+ if (pendingMonthFocusRef.current === null) return;
269
+ const index: number = pendingMonthFocusRef.current;
270
+ pendingMonthFocusRef.current = null;
271
+ monthRefs.current[index]?.focus();
272
+ });
273
+
274
+ function openMonthGrid(): void {
275
+ setFocusMonth(viewMonth.getMonth());
276
+ setMode('months');
277
+ pendingMonthFocusRef.current = viewMonth.getMonth();
278
+ }
279
+
280
+ /* Picking a month is NAVIGATION, not selection: back to the day grid with the roving cell clamped
281
+ into the new month, so nothing is committed until a day is chosen. */
282
+ function openMonth(index: number): void {
283
+ const year: number = viewMonth.getFullYear();
284
+ const daysInMonth: number = new Date(year, index + 1, 0).getDate();
285
+ const next: Date = new Date(year, index, Math.min(focusDate.getDate(), daysInMonth));
286
+ setViewMonth(new Date(year, index, 1));
287
+ setFocusDate(next);
288
+ setMode('days');
289
+ pendingFocusRef.current = toISO(next);
290
+ }
291
+
292
+ function moveMonthFocus(index: number, yearDelta: number): void {
293
+ if (yearDelta !== 0) setViewMonth(addMonths(viewMonth, yearDelta * 12));
294
+ const next: number = (index + 12) % 12;
295
+ setFocusMonth(next);
296
+ pendingMonthFocusRef.current = next;
297
+ }
298
+
299
+ function onMonthKeydown(e: React.KeyboardEvent): void {
300
+ let handled: boolean = true;
301
+ switch (e.key) {
302
+ case 'ArrowLeft': moveMonthFocus(focusMonth - 1, 0); break;
303
+ case 'ArrowRight': moveMonthFocus(focusMonth + 1, 0); break;
304
+ case 'ArrowUp': moveMonthFocus(focusMonth - 3, 0); break;
305
+ case 'ArrowDown': moveMonthFocus(focusMonth + 3, 0); break;
306
+ case 'Home': moveMonthFocus(0, 0); break;
307
+ case 'End': moveMonthFocus(11, 0); break;
308
+ case 'PageUp': moveMonthFocus(focusMonth, -1); break;
309
+ case 'PageDown': moveMonthFocus(focusMonth, 1); break;
310
+ case 'Enter':
311
+ case ' ': openMonth(focusMonth); break;
312
+ case 'Escape': closePanel(true); break;
313
+ default: handled = false;
314
+ }
315
+ if (handled) e.preventDefault();
316
+ }
317
+
206
318
  function moveFocus(next: Date): void {
207
319
  setFocusDate(next);
208
320
  setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
@@ -295,8 +407,14 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
295
407
  }
296
408
 
297
409
  // Close when focus leaves the control entirely (e.g. Shift+Tab off the trigger).
410
+ /* A null relatedTarget means focus fell to <body> — which is what happens when the element the
411
+ user just pressed is REMOVED by the click it triggered (drilling into the month grid replaces
412
+ the grid, and with it the cell that had focus). That is not focus leaving the control, and
413
+ closing on it made the panel vanish mid-navigation. A pointer that really lands outside is
414
+ handled by the mousedown listener, which is the reliable path for that case. */
298
415
  const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
299
416
  const next: EventTarget | null = e.relatedTarget;
417
+ if (next === null) return;
300
418
  if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closePanel(false);
301
419
  };
302
420
 
@@ -352,15 +470,55 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
352
470
  ) : null}
353
471
 
354
472
  <div ref={panelRef} className="fdy-datepicker__panel" role="dialog" aria-modal="false" aria-labelledby={titleId} hidden={!open}>
473
+ {/* ONE head for both modes on purpose: swapping it destroys the very button the user just
474
+ pressed, focus falls to <body>, and the panel's own focusout handler closes it. */}
355
475
  <div className="fdy-cal__head">
356
- <button type="button" className="fdy-cal__nav" aria-label={prevMonthLabelText} onClick={(): void => setViewMonth(addMonths(viewMonth, -1))}>
476
+ <button
477
+ type="button"
478
+ className="fdy-cal__nav"
479
+ aria-label={mode === 'months' ? prevYearLabelText : prevMonthLabelText}
480
+ onClick={(): void => { if (mode === 'months') moveMonthFocus(focusMonth, -1); else setViewMonth(addMonths(viewMonth, -1)); }}
481
+ >
357
482
  &lsaquo;
358
483
  </button>
359
- <div id={titleId} className="fdy-cal__title">{monthFmt.format(viewMonth)}</div>
360
- <button type="button" className="fdy-cal__nav" aria-label={nextMonthLabelText} onClick={(): void => setViewMonth(addMonths(viewMonth, 1))}>
484
+ <button
485
+ id={titleId}
486
+ type="button"
487
+ className="fdy-cal__title"
488
+ aria-label={mode === 'months' ? undefined : chooseMonthLabelText}
489
+ onClick={(): void => { if (mode === 'months') setMode('days'); else openMonthGrid(); }}
490
+ >
491
+ {mode === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
492
+ </button>
493
+ <button
494
+ type="button"
495
+ className="fdy-cal__nav"
496
+ aria-label={mode === 'months' ? nextYearLabelText : nextMonthLabelText}
497
+ onClick={(): void => { if (mode === 'months') moveMonthFocus(focusMonth, 1); else setViewMonth(addMonths(viewMonth, 1)); }}
498
+ >
361
499
  &rsaquo;
362
500
  </button>
363
501
  </div>
502
+ {mode === 'months' ? (
503
+ <div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
504
+ {monthCells.map((cell: MonthCell) => (
505
+ <button
506
+ key={cell.index}
507
+ ref={(el: HTMLButtonElement | null): void => { monthRefs.current[cell.index] = el; }}
508
+ type="button"
509
+ className={['fdy-cal__month', cell.today ? 'is-today' : '', cell.selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
510
+ role="gridcell"
511
+ aria-label={cell.ariaLabel}
512
+ aria-selected={cell.selected ? true : undefined}
513
+ disabled={cell.disabled}
514
+ tabIndex={cell.focusable ? 0 : -1}
515
+ onClick={(): void => openMonth(cell.index)}
516
+ >
517
+ {cell.label}
518
+ </button>
519
+ ))}
520
+ </div>
521
+ ) : (
364
522
  <div className="fdy-cal__grid" role="grid" aria-labelledby={titleId} onKeyDown={onGridKeydown}>
365
523
  {weekdayHeaders.map((w: string): JSX.Element => (
366
524
  <div key={w} className="fdy-cal__dow" role="columnheader" aria-label={w}>{w}</div>
@@ -384,6 +542,8 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
384
542
  </button>
385
543
  ))}
386
544
  </div>
545
+ )}
546
+
387
547
  </div>
388
548
  </div>
389
549
  );
@@ -233,7 +233,7 @@ onBeforeUnmount((): void => {
233
233
  @click="choose(i)"
234
234
  @mousemove="setHighlight(i)"
235
235
  >
236
- <span class="fdy-combo__check">{{ opt.value === modelValue ? '✓' : '' }}</span>{{ opt.label }}
236
+ <span class="fdy-combo__check"></span>{{ opt.label }}
237
237
  </li>
238
238
  </ul>
239
239
  </div>
@@ -45,6 +45,11 @@ const props = defineProps<{
45
45
  nextMonthLabel?: string;
46
46
  /** aria-label for the clear button (when `clearable`). Default 'Clear date'. */
47
47
  clearLabel?: string;
48
+ /** aria-label for the title button that drills to the month grid. Default 'Choose month'. */
49
+ chooseMonthLabel?: string;
50
+ /** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
51
+ prevYearLabel?: string;
52
+ nextYearLabel?: string;
48
53
  }>();
49
54
 
50
55
  const emit = defineEmits<{
@@ -116,6 +121,9 @@ const showClear: ComputedRef<boolean> = computed(
116
121
  const prevMonthLabelText: ComputedRef<string> = computed((): string => props.prevMonthLabel ?? 'Previous month');
117
122
  const nextMonthLabelText: ComputedRef<string> = computed((): string => props.nextMonthLabel ?? 'Next month');
118
123
  const clearLabelText: ComputedRef<string> = computed((): string => props.clearLabel ?? 'Clear date');
124
+ const chooseMonthLabelText: ComputedRef<string> = computed((): string => props.chooseMonthLabel ?? 'Choose month');
125
+ const prevYearLabelText: ComputedRef<string> = computed((): string => props.prevYearLabel ?? 'Previous year');
126
+ const nextYearLabelText: ComputedRef<string> = computed((): string => props.nextYearLabel ?? 'Next year');
119
127
 
120
128
  const view: Ref<Date> = ref(startOfMonth(selectedDate.value ?? new Date()));
121
129
  const focusDate: Ref<Date> = ref(selectedDate.value ?? new Date());
@@ -129,6 +137,58 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
129
137
  return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
130
138
  });
131
139
 
140
+ /* 'days' | 'months' — the calendar drills one level up instead of growing furniture beside the title.
141
+ Before this the only pointer route to another month was one click per month: August 2026 to March
142
+ 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all. */
143
+ const mode: Ref<'days' | 'months'> = ref('days');
144
+ const focusMonth: Ref<number> = ref(0);
145
+
146
+ const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
147
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
148
+ );
149
+ const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
150
+ (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
151
+ );
152
+
153
+ interface MonthCell {
154
+ index: number;
155
+ label: string;
156
+ ariaLabel: string;
157
+ selected: boolean;
158
+ today: boolean;
159
+ disabled: boolean;
160
+ focusable: boolean;
161
+ }
162
+
163
+ /** A month is only unreachable when the WHOLE month falls outside min/max. Checked against the
164
+ * bounds directly, not by testing both ends with isDayDisabled: a range that sits INSIDE one month
165
+ * disables both ends while the middle is perfectly selectable. */
166
+ function isMonthDisabled(year: number, month: number): boolean {
167
+ const first: Date = new Date(year, month, 1);
168
+ const last: Date = new Date(year, month + 1, 0);
169
+ if (minDate.value !== null && startOfDay(last).getTime() < startOfDay(minDate.value).getTime()) return true;
170
+ if (maxDate.value !== null && startOfDay(first).getTime() > startOfDay(maxDate.value).getTime()) return true;
171
+ return false;
172
+ }
173
+
174
+ const monthCells: ComputedRef<MonthCell[]> = computed((): MonthCell[] => {
175
+ const year: number = view.value.getFullYear();
176
+ const today: Date = new Date();
177
+ const sel: Date | null = selectedDate.value;
178
+ return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
179
+ const cellDate: Date = new Date(year, index, 1);
180
+ return {
181
+ index,
182
+ label: monthCellFmt.value.format(cellDate),
183
+ ariaLabel: monthNameFmt.value.format(cellDate),
184
+ selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
185
+ today: today.getFullYear() === year && today.getMonth() === index,
186
+ disabled: isMonthDisabled(year, index),
187
+ focusable: index === focusMonth.value,
188
+ };
189
+ });
190
+ });
191
+
132
192
  const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
133
193
  (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
134
194
  );
@@ -195,6 +255,54 @@ function moveFocus(next: Date): void {
195
255
  void nextTick((): void => focusCell());
196
256
  }
197
257
 
258
+ function openMonthGrid(): void {
259
+ focusMonth.value = view.value.getMonth();
260
+ mode.value = 'months';
261
+ void nextTick((): void => focusMonthCell());
262
+ }
263
+
264
+ function focusMonthCell(): void {
265
+ const cell: HTMLElement | null = panelEl.value?.querySelector('.fdy-cal__month[tabindex="0"]') ?? null;
266
+ cell?.focus();
267
+ }
268
+
269
+ /* Picking a month is NAVIGATION, not selection: it drops back to the day grid with the roving cell
270
+ clamped into the new month, so nothing is committed until a day is chosen. */
271
+ function openMonth(index: number): void {
272
+ const year: number = view.value.getFullYear();
273
+ const daysInMonth: number = new Date(year, index + 1, 0).getDate();
274
+ const day: number = Math.min(focusDate.value.getDate(), daysInMonth);
275
+ view.value = new Date(year, index, 1);
276
+ focusDate.value = new Date(year, index, day);
277
+ mode.value = 'days';
278
+ void nextTick((): void => focusCell());
279
+ }
280
+
281
+ function moveMonthFocus(index: number, yearDelta: number): void {
282
+ if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
283
+ focusMonth.value = (index + 12) % 12;
284
+ void nextTick((): void => focusMonthCell());
285
+ }
286
+
287
+ function onMonthKeydown(e: KeyboardEvent): void {
288
+ let handled: boolean = true;
289
+ switch (e.key) {
290
+ case 'ArrowLeft': moveMonthFocus(focusMonth.value - 1, 0); break;
291
+ case 'ArrowRight': moveMonthFocus(focusMonth.value + 1, 0); break;
292
+ case 'ArrowUp': moveMonthFocus(focusMonth.value - 3, 0); break;
293
+ case 'ArrowDown': moveMonthFocus(focusMonth.value + 3, 0); break;
294
+ case 'Home': moveMonthFocus(0, 0); break;
295
+ case 'End': moveMonthFocus(11, 0); break;
296
+ case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
297
+ case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
298
+ case 'Enter':
299
+ case ' ': openMonth(focusMonth.value); break;
300
+ case 'Escape': closePanel(true); break;
301
+ default: handled = false;
302
+ }
303
+ if (handled) e.preventDefault();
304
+ }
305
+
198
306
  function pick(d: Date): void {
199
307
  if (isDayDisabled(d)) return;
200
308
  const day: Date = startOfDay(d);
@@ -290,8 +398,14 @@ function onDocPointerDown(e: MouseEvent): void {
290
398
  }
291
399
 
292
400
  // Close when focus leaves the control entirely (e.g. Shift+Tab off the trigger).
401
+ /* A null relatedTarget means focus fell to <body> — which is what happens when the element the
402
+ * user just pressed is REMOVED by the click it triggered (drilling into the month grid replaces
403
+ * the grid, and with it the cell that had focus). That is not focus leaving the control, and
404
+ * closing on it made the panel vanish mid-navigation. A pointer that really lands outside is
405
+ * handled by the mousedown listener, which is the reliable path for that case. */
293
406
  function onFocusout(e: FocusEvent): void {
294
407
  const next: EventTarget | null = e.relatedTarget;
408
+ if (next === null) return;
295
409
  if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
296
410
  closePanel(false);
297
411
  }
@@ -344,12 +458,46 @@ onBeforeUnmount((): void => {
344
458
  </button>
345
459
 
346
460
  <div ref="panelEl" class="fdy-datepicker__panel" role="dialog" aria-modal="false" popover="manual" :aria-labelledby="titleId" :hidden="!open">
461
+ <!-- ONE head for both modes on purpose: swapping it with v-if destroys the very button the user
462
+ just pressed, focus falls to <body>, and the panel's own focusout handler closes it. The
463
+ labels and handlers switch; the elements do not. -->
347
464
  <div class="fdy-cal__head">
348
- <button type="button" class="fdy-cal__nav" :aria-label="prevMonthLabelText" @click="view = addMonths(view, -1)">‹</button>
349
- <div :id="titleId" class="fdy-cal__title">{{ monthFmt.format(view) }}</div>
350
- <button type="button" class="fdy-cal__nav" :aria-label="nextMonthLabelText" @click="view = addMonths(view, 1)">›</button>
465
+ <button
466
+ type="button"
467
+ class="fdy-cal__nav"
468
+ :aria-label="mode === 'months' ? prevYearLabelText : prevMonthLabelText"
469
+ @click="mode === 'months' ? moveMonthFocus(focusMonth, -1) : (view = addMonths(view, -1))"
470
+ >‹</button>
471
+ <button
472
+ :id="titleId"
473
+ type="button"
474
+ class="fdy-cal__title"
475
+ :aria-label="mode === 'months' ? undefined : chooseMonthLabelText"
476
+ @click="mode === 'months' ? (mode = 'days') : openMonthGrid()"
477
+ >{{ mode === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
478
+ <button
479
+ type="button"
480
+ class="fdy-cal__nav"
481
+ :aria-label="mode === 'months' ? nextYearLabelText : nextMonthLabelText"
482
+ @click="mode === 'months' ? moveMonthFocus(focusMonth, 1) : (view = addMonths(view, 1))"
483
+ >›</button>
484
+ </div>
485
+ <div v-if="mode === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
486
+ <button
487
+ v-for="cell in monthCells"
488
+ :key="cell.index"
489
+ type="button"
490
+ class="fdy-cal__month"
491
+ :class="{ 'is-today': cell.today, 'is-selected': cell.selected }"
492
+ role="gridcell"
493
+ :aria-label="cell.ariaLabel"
494
+ :aria-selected="cell.selected ? 'true' : undefined"
495
+ :disabled="cell.disabled"
496
+ :tabindex="cell.focusable ? 0 : -1"
497
+ @click="openMonth(cell.index)"
498
+ >{{ cell.label }}</button>
351
499
  </div>
352
- <div class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">
500
+ <div v-else class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">
353
501
  <div v-for="w in weekdayHeaders" :key="w" class="fdy-cal__dow" role="columnheader" :aria-label="w">{{ w }}</div>
354
502
  <button
355
503
  v-for="cell in gridCells"
@@ -39,6 +39,8 @@
39
39
  var monthFmt = new Intl.DateTimeFormat(LOCALE, { month: 'long', year: 'numeric' });
40
40
  var valueFmt = new Intl.DateTimeFormat(LOCALE, { day: '2-digit', month: 'short', year: 'numeric' });
41
41
  var dayLabelFmt = new Intl.DateTimeFormat(LOCALE, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
42
+ var monthCellFmt = new Intl.DateTimeFormat(LOCALE, { month: 'short' });
43
+ var monthNameFmt = new Intl.DateTimeFormat(LOCALE, { month: 'long', year: 'numeric' });
42
44
 
43
45
  function weekdayHeaders() {
44
46
  var monday = new Date(2024, 0, 1); // a known Monday
@@ -64,6 +66,13 @@
64
66
  var view = startOfDay(selected || new Date());
65
67
  view = new Date(view.getFullYear(), view.getMonth(), 1);
66
68
  var focusDate = null;
69
+ /* 'days' | 'months' — the calendar drills one level up rather than growing furniture beside the
70
+ title. Before this, the only pointer route to another month was one click per month: from
71
+ August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
72
+ shortcut nobody can see is not an affordance — the repo's own test helper clicked "previous
73
+ month" twenty-four times to reach a date two years back. */
74
+ var mode = 'days';
75
+ var focusMonth = null;
67
76
 
68
77
  var trigger = document.createElement('button');
69
78
  trigger.type = 'button';
@@ -111,6 +120,25 @@
111
120
  return false;
112
121
  }
113
122
 
123
+ function monthDisabled(year, month) {
124
+ var last = new Date(year, month + 1, 0);
125
+ var first = new Date(year, month, 1);
126
+ if (minDate && startOfDay(last).getTime() < startOfDay(minDate).getTime()) return true;
127
+ if (maxDate && startOfDay(first).getTime() > startOfDay(maxDate).getTime()) return true;
128
+ return false;
129
+ }
130
+
131
+ function titleButton(text, aria, onClick) {
132
+ var b = document.createElement('button');
133
+ b.type = 'button';
134
+ b.className = 'fdy-cal__title';
135
+ b.id = uid('fdy-cal-title');
136
+ b.textContent = text;
137
+ b.setAttribute('aria-label', aria);
138
+ b.addEventListener('click', onClick);
139
+ return b;
140
+ }
141
+
114
142
  function navButton(glyph, aria, onClick) {
115
143
  var b = document.createElement('button');
116
144
  b.type = 'button';
@@ -122,13 +150,16 @@
122
150
  }
123
151
 
124
152
  function render() {
153
+ if (mode === 'months') { renderMonths(); return; }
125
154
  panel.innerHTML = '';
126
155
  var head = document.createElement('div');
127
156
  head.className = 'fdy-cal__head';
128
- var title = document.createElement('div');
129
- title.className = 'fdy-cal__title';
130
- title.id = uid('fdy-cal-title');
131
- title.textContent = monthFmt.format(view);
157
+ var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', pilih bulan', function () {
158
+ mode = 'months';
159
+ focusMonth = view.getMonth();
160
+ render();
161
+ focusMonthCell();
162
+ });
132
163
  panel.setAttribute('aria-labelledby', title.id);
133
164
  head.appendChild(navButton('‹', 'Bulan sebelumnya', function () { view = addMonths(view, -1); render(); }));
134
165
  head.appendChild(title);
@@ -184,6 +215,93 @@
184
215
  panel.appendChild(grid);
185
216
  }
186
217
 
218
+ function renderMonths() {
219
+ panel.innerHTML = '';
220
+ var year = view.getFullYear();
221
+ var head = document.createElement('div');
222
+ head.className = 'fdy-cal__head';
223
+ var title = titleButton(String(year), year + ', kembali ke tanggal', function () {
224
+ mode = 'days';
225
+ render();
226
+ focusFocusDate();
227
+ });
228
+ panel.setAttribute('aria-labelledby', title.id);
229
+ head.appendChild(navButton('‹', 'Tahun sebelumnya', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
230
+ head.appendChild(title);
231
+ head.appendChild(navButton('›', 'Tahun berikutnya', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
232
+ panel.appendChild(head);
233
+
234
+ var grid = document.createElement('div');
235
+ grid.className = 'fdy-cal__grid fdy-cal__grid--months';
236
+ grid.setAttribute('role', 'grid');
237
+ grid.setAttribute('aria-labelledby', title.id);
238
+ var today = new Date();
239
+ if (focusMonth === null) focusMonth = view.getMonth();
240
+ for (var m = 0; m < 12; m++) {
241
+ var cellDate = new Date(year, m, 1);
242
+ var btn = document.createElement('button');
243
+ btn.type = 'button';
244
+ btn.className = 'fdy-cal__month';
245
+ btn.setAttribute('role', 'gridcell');
246
+ btn.setAttribute('aria-label', monthNameFmt.format(cellDate));
247
+ btn.textContent = monthCellFmt.format(cellDate);
248
+ if (today.getFullYear() === year && today.getMonth() === m) btn.classList.add('is-today');
249
+ if (selected && selected.getFullYear() === year && selected.getMonth() === m) {
250
+ btn.classList.add('is-selected');
251
+ btn.setAttribute('aria-selected', 'true');
252
+ }
253
+ if (monthDisabled(year, m)) btn.disabled = true;
254
+ btn.tabIndex = m === focusMonth ? 0 : -1;
255
+ btn.addEventListener('click', (function (mm) { return function () { openMonth(mm); }; })(m));
256
+ grid.appendChild(btn);
257
+ }
258
+ grid.addEventListener('keydown', onMonthKey);
259
+ panel.appendChild(grid);
260
+ }
261
+
262
+ function focusMonthCell() {
263
+ var cell = panel.querySelector('.fdy-cal__month[tabindex="0"]');
264
+ if (cell) cell.focus();
265
+ }
266
+
267
+ /* Picking a month is navigation, not selection: it drops back to the day grid with the roving
268
+ cell clamped into the new month, so nothing is committed until a DAY is chosen. */
269
+ function openMonth(m) {
270
+ var dim = new Date(view.getFullYear(), m + 1, 0).getDate();
271
+ var day = focusDate ? Math.min(focusDate.getDate(), dim) : 1;
272
+ view = new Date(view.getFullYear(), m, 1);
273
+ focusDate = new Date(view.getFullYear(), m, day);
274
+ mode = 'days';
275
+ render();
276
+ focusFocusDate();
277
+ }
278
+
279
+ function moveMonthFocus(index, yearDelta) {
280
+ if (yearDelta) view = addMonths(view, yearDelta * 12);
281
+ focusMonth = (index + 12) % 12;
282
+ render();
283
+ focusMonthCell();
284
+ }
285
+
286
+ function onMonthKey(e) {
287
+ var handled = true;
288
+ switch (e.key) {
289
+ case 'ArrowLeft': moveMonthFocus(focusMonth - 1, 0); break;
290
+ case 'ArrowRight': moveMonthFocus(focusMonth + 1, 0); break;
291
+ case 'ArrowUp': moveMonthFocus(focusMonth - 3, 0); break;
292
+ case 'ArrowDown': moveMonthFocus(focusMonth + 3, 0); break;
293
+ case 'Home': moveMonthFocus(0, 0); break;
294
+ case 'End': moveMonthFocus(11, 0); break;
295
+ case 'PageUp': moveMonthFocus(focusMonth, -1); break;
296
+ case 'PageDown': moveMonthFocus(focusMonth, 1); break;
297
+ case 'Enter':
298
+ case ' ': openMonth(focusMonth); break;
299
+ case 'Escape': close(true); break;
300
+ default: handled = false;
301
+ }
302
+ if (handled) e.preventDefault();
303
+ }
304
+
187
305
  function focusFocusDate() {
188
306
  var cell = panel.querySelector('.fdy-cal__day[tabindex="0"]');
189
307
  if (cell) cell.focus();
@@ -232,6 +350,7 @@
232
350
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
233
351
  function open() {
234
352
  if (!panel.hidden) return;
353
+ mode = 'days';
235
354
  focusDate = selected || focusDate || new Date();
236
355
  view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
237
356
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
@@ -13,7 +13,7 @@
13
13
  * </button>
14
14
  * <ul class="fdy-combo__listbox" role="listbox" hidden>
15
15
  * <li class="fdy-combo__option" role="option" data-value="x"
16
- * aria-selected="true"><span class="fdy-combo__check">✓</span>Label</li>
16
+ * aria-selected="true"><span class="fdy-combo__check"></span>Label</li>
17
17
  * </ul>
18
18
  * </div>
19
19
  *
@@ -25,10 +25,9 @@
25
25
  var seq = 0;
26
26
  var openClose = null; // close() of the currently-open combo, or null
27
27
 
28
+ /* The tick lives in CSS now, so the option's text IS its label — no sibling walk to skip a glyph. */
28
29
  function optionLabel(opt) {
29
- var check = opt.querySelector('.fdy-combo__check');
30
- var node = check && check.nextSibling ? check.nextSibling.textContent : opt.textContent;
31
- return (node || '').trim();
30
+ return (opt.textContent || '').trim();
32
31
  }
33
32
 
34
33
  function initCombo(root) {
@@ -99,8 +98,6 @@
99
98
  options.forEach(function (other) {
100
99
  var on = other === opt;
101
100
  other.setAttribute('aria-selected', on ? 'true' : 'false');
102
- var check = other.querySelector('.fdy-combo__check');
103
- if (check) check.textContent = on ? '✓' : '';
104
101
  });
105
102
  root.setAttribute('data-value', opt.getAttribute('data-value') || '');
106
103
  valueEl.textContent = optionLabel(opt);
@@ -1010,7 +1010,14 @@ a { color: var(--color-primary); }
1010
1010
  .fdy-combo__option.is-highlighted{background:var(--color-surface-3);}
1011
1011
  .fdy-combo__option[aria-selected="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
1012
1012
  .fdy-combo__option.is-highlighted[aria-selected="true"]{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--color-primary) 55%,transparent);}
1013
+ /* The tick is CSS, not content (#004 §2). Inside role="option" it joined the option's ACCESSIBLE
1014
+ NAME, so the selected option announced "✓August" while every other announced "August": the state
1015
+ was read twice — once as aria-selected, once as decoration — and a test asking for an option by
1016
+ its own label stopped matching the one that was selected. The `/ ""` is alt text: the glyph is
1017
+ painted, the accessibility tree gets nothing. Same reasoning as .fdy-label--required (1.29.0).
1018
+ The empty box still reserves its width, so labels stay aligned whether selected or not. */
1013
1019
  .fdy-combo__check{display:inline-flex;justify-content:center;width:var(--space-4);flex:none;font-weight:var(--weight-bold);color:var(--color-primary);}
1020
+ .fdy-combo__option[aria-selected="true"] .fdy-combo__check::after{content:"✓" / "";}
1014
1021
  @media (prefers-reduced-motion:no-preference){.fdy-combo__listbox:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
1015
1022
  @keyframes fdy-combo-in{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:none;}}
1016
1023
 
@@ -1114,7 +1121,20 @@ a { color: var(--color-primary); }
1114
1121
 
1115
1122
  /* Calendar grid */
1116
1123
  .fdy-cal__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);}
1117
- .fdy-cal__title{flex:1;text-align:center;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;}
1124
+ /* The title is a CONTROL now (#004 §1): it drills to the month grid, so it needs the UA button box
1125
+ reset and the same hover/focus treatment as the arrows beside it. */
1126
+ .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);text-align:center;font-family:inherit;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;cursor:pointer;}
1127
+ .fdy-cal__title:hover{background:var(--color-surface-2);}
1128
+ .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1129
+ /* Month grid — 3×4 over the same track width as the 7-column day grid, so the panel does not resize
1130
+ when you drill and the pointer does not have to travel. */
1131
+ .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
1132
+ .fdy-cal__month{display:inline-flex;align-items:center;justify-content:center;height:2.25rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-family:inherit;font-size:var(--text-sm);text-transform:capitalize;cursor:pointer;}
1133
+ .fdy-cal__month:hover{background:var(--color-surface-2);}
1134
+ .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1135
+ .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
1136
+ .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
1137
+ .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
1118
1138
  .fdy-cal__nav{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;flex:none;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-muted);font-size:var(--text-lg);line-height:1;cursor:pointer;}
1119
1139
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
1120
1140
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
package/dist/freeday.css CHANGED
@@ -627,7 +627,14 @@ a { color: var(--color-primary); }
627
627
  .fdy-combo__option.is-highlighted{background:var(--color-surface-3);}
628
628
  .fdy-combo__option[aria-selected="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
629
629
  .fdy-combo__option.is-highlighted[aria-selected="true"]{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--color-primary) 55%,transparent);}
630
+ /* The tick is CSS, not content (#004 §2). Inside role="option" it joined the option's ACCESSIBLE
631
+ NAME, so the selected option announced "✓August" while every other announced "August": the state
632
+ was read twice — once as aria-selected, once as decoration — and a test asking for an option by
633
+ its own label stopped matching the one that was selected. The `/ ""` is alt text: the glyph is
634
+ painted, the accessibility tree gets nothing. Same reasoning as .fdy-label--required (1.29.0).
635
+ The empty box still reserves its width, so labels stay aligned whether selected or not. */
630
636
  .fdy-combo__check{display:inline-flex;justify-content:center;width:var(--space-4);flex:none;font-weight:var(--weight-bold);color:var(--color-primary);}
637
+ .fdy-combo__option[aria-selected="true"] .fdy-combo__check::after{content:"✓" / "";}
631
638
  @media (prefers-reduced-motion:no-preference){.fdy-combo__listbox:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
632
639
  @keyframes fdy-combo-in{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:none;}}
633
640
 
@@ -731,7 +738,20 @@ a { color: var(--color-primary); }
731
738
 
732
739
  /* Calendar grid */
733
740
  .fdy-cal__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);}
734
- .fdy-cal__title{flex:1;text-align:center;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;}
741
+ /* The title is a CONTROL now (#004 §1): it drills to the month grid, so it needs the UA button box
742
+ reset and the same hover/focus treatment as the arrows beside it. */
743
+ .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);text-align:center;font-family:inherit;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;cursor:pointer;}
744
+ .fdy-cal__title:hover{background:var(--color-surface-2);}
745
+ .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
746
+ /* Month grid — 3×4 over the same track width as the 7-column day grid, so the panel does not resize
747
+ when you drill and the pointer does not have to travel. */
748
+ .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
749
+ .fdy-cal__month{display:inline-flex;align-items:center;justify-content:center;height:2.25rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-family:inherit;font-size:var(--text-sm);text-transform:capitalize;cursor:pointer;}
750
+ .fdy-cal__month:hover{background:var(--color-surface-2);}
751
+ .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
752
+ .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
753
+ .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
754
+ .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
735
755
  .fdy-cal__nav{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;flex:none;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-muted);font-size:var(--text-lg);line-height:1;cursor:pointer;}
736
756
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
737
757
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
package/dist/freeday.js CHANGED
@@ -1346,6 +1346,8 @@
1346
1346
  var monthFmt = new Intl.DateTimeFormat(LOCALE, { month: 'long', year: 'numeric' });
1347
1347
  var valueFmt = new Intl.DateTimeFormat(LOCALE, { day: '2-digit', month: 'short', year: 'numeric' });
1348
1348
  var dayLabelFmt = new Intl.DateTimeFormat(LOCALE, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
1349
+ var monthCellFmt = new Intl.DateTimeFormat(LOCALE, { month: 'short' });
1350
+ var monthNameFmt = new Intl.DateTimeFormat(LOCALE, { month: 'long', year: 'numeric' });
1349
1351
 
1350
1352
  function weekdayHeaders() {
1351
1353
  var monday = new Date(2024, 0, 1); // a known Monday
@@ -1371,6 +1373,13 @@
1371
1373
  var view = startOfDay(selected || new Date());
1372
1374
  view = new Date(view.getFullYear(), view.getMonth(), 1);
1373
1375
  var focusDate = null;
1376
+ /* 'days' | 'months' — the calendar drills one level up rather than growing furniture beside the
1377
+ title. Before this, the only pointer route to another month was one click per month: from
1378
+ August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
1379
+ shortcut nobody can see is not an affordance — the repo's own test helper clicked "previous
1380
+ month" twenty-four times to reach a date two years back. */
1381
+ var mode = 'days';
1382
+ var focusMonth = null;
1374
1383
 
1375
1384
  var trigger = document.createElement('button');
1376
1385
  trigger.type = 'button';
@@ -1418,6 +1427,25 @@
1418
1427
  return false;
1419
1428
  }
1420
1429
 
1430
+ function monthDisabled(year, month) {
1431
+ var last = new Date(year, month + 1, 0);
1432
+ var first = new Date(year, month, 1);
1433
+ if (minDate && startOfDay(last).getTime() < startOfDay(minDate).getTime()) return true;
1434
+ if (maxDate && startOfDay(first).getTime() > startOfDay(maxDate).getTime()) return true;
1435
+ return false;
1436
+ }
1437
+
1438
+ function titleButton(text, aria, onClick) {
1439
+ var b = document.createElement('button');
1440
+ b.type = 'button';
1441
+ b.className = 'fdy-cal__title';
1442
+ b.id = uid('fdy-cal-title');
1443
+ b.textContent = text;
1444
+ b.setAttribute('aria-label', aria);
1445
+ b.addEventListener('click', onClick);
1446
+ return b;
1447
+ }
1448
+
1421
1449
  function navButton(glyph, aria, onClick) {
1422
1450
  var b = document.createElement('button');
1423
1451
  b.type = 'button';
@@ -1429,13 +1457,16 @@
1429
1457
  }
1430
1458
 
1431
1459
  function render() {
1460
+ if (mode === 'months') { renderMonths(); return; }
1432
1461
  panel.innerHTML = '';
1433
1462
  var head = document.createElement('div');
1434
1463
  head.className = 'fdy-cal__head';
1435
- var title = document.createElement('div');
1436
- title.className = 'fdy-cal__title';
1437
- title.id = uid('fdy-cal-title');
1438
- title.textContent = monthFmt.format(view);
1464
+ var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', pilih bulan', function () {
1465
+ mode = 'months';
1466
+ focusMonth = view.getMonth();
1467
+ render();
1468
+ focusMonthCell();
1469
+ });
1439
1470
  panel.setAttribute('aria-labelledby', title.id);
1440
1471
  head.appendChild(navButton('‹', 'Bulan sebelumnya', function () { view = addMonths(view, -1); render(); }));
1441
1472
  head.appendChild(title);
@@ -1491,6 +1522,93 @@
1491
1522
  panel.appendChild(grid);
1492
1523
  }
1493
1524
 
1525
+ function renderMonths() {
1526
+ panel.innerHTML = '';
1527
+ var year = view.getFullYear();
1528
+ var head = document.createElement('div');
1529
+ head.className = 'fdy-cal__head';
1530
+ var title = titleButton(String(year), year + ', kembali ke tanggal', function () {
1531
+ mode = 'days';
1532
+ render();
1533
+ focusFocusDate();
1534
+ });
1535
+ panel.setAttribute('aria-labelledby', title.id);
1536
+ head.appendChild(navButton('‹', 'Tahun sebelumnya', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
1537
+ head.appendChild(title);
1538
+ head.appendChild(navButton('›', 'Tahun berikutnya', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
1539
+ panel.appendChild(head);
1540
+
1541
+ var grid = document.createElement('div');
1542
+ grid.className = 'fdy-cal__grid fdy-cal__grid--months';
1543
+ grid.setAttribute('role', 'grid');
1544
+ grid.setAttribute('aria-labelledby', title.id);
1545
+ var today = new Date();
1546
+ if (focusMonth === null) focusMonth = view.getMonth();
1547
+ for (var m = 0; m < 12; m++) {
1548
+ var cellDate = new Date(year, m, 1);
1549
+ var btn = document.createElement('button');
1550
+ btn.type = 'button';
1551
+ btn.className = 'fdy-cal__month';
1552
+ btn.setAttribute('role', 'gridcell');
1553
+ btn.setAttribute('aria-label', monthNameFmt.format(cellDate));
1554
+ btn.textContent = monthCellFmt.format(cellDate);
1555
+ if (today.getFullYear() === year && today.getMonth() === m) btn.classList.add('is-today');
1556
+ if (selected && selected.getFullYear() === year && selected.getMonth() === m) {
1557
+ btn.classList.add('is-selected');
1558
+ btn.setAttribute('aria-selected', 'true');
1559
+ }
1560
+ if (monthDisabled(year, m)) btn.disabled = true;
1561
+ btn.tabIndex = m === focusMonth ? 0 : -1;
1562
+ btn.addEventListener('click', (function (mm) { return function () { openMonth(mm); }; })(m));
1563
+ grid.appendChild(btn);
1564
+ }
1565
+ grid.addEventListener('keydown', onMonthKey);
1566
+ panel.appendChild(grid);
1567
+ }
1568
+
1569
+ function focusMonthCell() {
1570
+ var cell = panel.querySelector('.fdy-cal__month[tabindex="0"]');
1571
+ if (cell) cell.focus();
1572
+ }
1573
+
1574
+ /* Picking a month is navigation, not selection: it drops back to the day grid with the roving
1575
+ cell clamped into the new month, so nothing is committed until a DAY is chosen. */
1576
+ function openMonth(m) {
1577
+ var dim = new Date(view.getFullYear(), m + 1, 0).getDate();
1578
+ var day = focusDate ? Math.min(focusDate.getDate(), dim) : 1;
1579
+ view = new Date(view.getFullYear(), m, 1);
1580
+ focusDate = new Date(view.getFullYear(), m, day);
1581
+ mode = 'days';
1582
+ render();
1583
+ focusFocusDate();
1584
+ }
1585
+
1586
+ function moveMonthFocus(index, yearDelta) {
1587
+ if (yearDelta) view = addMonths(view, yearDelta * 12);
1588
+ focusMonth = (index + 12) % 12;
1589
+ render();
1590
+ focusMonthCell();
1591
+ }
1592
+
1593
+ function onMonthKey(e) {
1594
+ var handled = true;
1595
+ switch (e.key) {
1596
+ case 'ArrowLeft': moveMonthFocus(focusMonth - 1, 0); break;
1597
+ case 'ArrowRight': moveMonthFocus(focusMonth + 1, 0); break;
1598
+ case 'ArrowUp': moveMonthFocus(focusMonth - 3, 0); break;
1599
+ case 'ArrowDown': moveMonthFocus(focusMonth + 3, 0); break;
1600
+ case 'Home': moveMonthFocus(0, 0); break;
1601
+ case 'End': moveMonthFocus(11, 0); break;
1602
+ case 'PageUp': moveMonthFocus(focusMonth, -1); break;
1603
+ case 'PageDown': moveMonthFocus(focusMonth, 1); break;
1604
+ case 'Enter':
1605
+ case ' ': openMonth(focusMonth); break;
1606
+ case 'Escape': close(true); break;
1607
+ default: handled = false;
1608
+ }
1609
+ if (handled) e.preventDefault();
1610
+ }
1611
+
1494
1612
  function focusFocusDate() {
1495
1613
  var cell = panel.querySelector('.fdy-cal__day[tabindex="0"]');
1496
1614
  if (cell) cell.focus();
@@ -1539,6 +1657,7 @@
1539
1657
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
1540
1658
  function open() {
1541
1659
  if (!panel.hidden) return;
1660
+ mode = 'days';
1542
1661
  focusDate = selected || focusDate || new Date();
1543
1662
  view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
1544
1663
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
@@ -2410,7 +2529,7 @@
2410
2529
  * </button>
2411
2530
  * <ul class="fdy-combo__listbox" role="listbox" hidden>
2412
2531
  * <li class="fdy-combo__option" role="option" data-value="x"
2413
- * aria-selected="true"><span class="fdy-combo__check">✓</span>Label</li>
2532
+ * aria-selected="true"><span class="fdy-combo__check"></span>Label</li>
2414
2533
  * </ul>
2415
2534
  * </div>
2416
2535
  *
@@ -2422,10 +2541,9 @@
2422
2541
  var seq = 0;
2423
2542
  var openClose = null; // close() of the currently-open combo, or null
2424
2543
 
2544
+ /* The tick lives in CSS now, so the option's text IS its label — no sibling walk to skip a glyph. */
2425
2545
  function optionLabel(opt) {
2426
- var check = opt.querySelector('.fdy-combo__check');
2427
- var node = check && check.nextSibling ? check.nextSibling.textContent : opt.textContent;
2428
- return (node || '').trim();
2546
+ return (opt.textContent || '').trim();
2429
2547
  }
2430
2548
 
2431
2549
  function initCombo(root) {
@@ -2496,8 +2614,6 @@
2496
2614
  options.forEach(function (other) {
2497
2615
  var on = other === opt;
2498
2616
  other.setAttribute('aria-selected', on ? 'true' : 'false');
2499
- var check = other.querySelector('.fdy-combo__check');
2500
- if (check) check.textContent = on ? '✓' : '';
2501
2617
  });
2502
2618
  root.setAttribute('data-value', opt.getAttribute('data-value') || '');
2503
2619
  valueEl.textContent = optionLabel(opt);
@@ -184,7 +184,7 @@ live docs also have a copy button per component.
184
184
  ```bash
185
185
  npm i @cahyo-dimas/freeday
186
186
  ```
187
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.31.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.32.0"` (public npm package). `dist/` is
188
188
  committed and published → no build step; `npm ci` runs without auth.
189
189
 
190
190
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
@@ -211,7 +211,7 @@
211
211
  <span class="fdy-combo__value" id="f-st-v">All</span>
212
212
  </button>
213
213
  <ul class="fdy-combo__listbox" role="listbox" aria-labelledby="f-st" hidden>
214
- <li class="fdy-combo__option" role="option" data-value="all" aria-selected="true"><span class="fdy-combo__check">✓</span>All</li>
214
+ <li class="fdy-combo__option" role="option" data-value="all" aria-selected="true"><span class="fdy-combo__check"></span>All</li>
215
215
  <li class="fdy-combo__option" role="option" data-value="open" aria-selected="false"><span class="fdy-combo__check"></span>Open</li>
216
216
  <li class="fdy-combo__option" role="option" data-value="overdue" aria-selected="false"><span class="fdy-combo__check"></span>Overdue</li>
217
217
  </ul>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,13 @@
27
27
  .fdy-combo__option.is-highlighted{background:var(--color-surface-3);}
28
28
  .fdy-combo__option[aria-selected="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
29
29
  .fdy-combo__option.is-highlighted[aria-selected="true"]{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--color-primary) 55%,transparent);}
30
+ /* The tick is CSS, not content (#004 §2). Inside role="option" it joined the option's ACCESSIBLE
31
+ NAME, so the selected option announced "✓August" while every other announced "August": the state
32
+ was read twice — once as aria-selected, once as decoration — and a test asking for an option by
33
+ its own label stopped matching the one that was selected. The `/ ""` is alt text: the glyph is
34
+ painted, the accessibility tree gets nothing. Same reasoning as .fdy-label--required (1.29.0).
35
+ The empty box still reserves its width, so labels stay aligned whether selected or not. */
30
36
  .fdy-combo__check{display:inline-flex;justify-content:center;width:var(--space-4);flex:none;font-weight:var(--weight-bold);color:var(--color-primary);}
37
+ .fdy-combo__option[aria-selected="true"] .fdy-combo__check::after{content:"✓" / "";}
31
38
  @media (prefers-reduced-motion:no-preference){.fdy-combo__listbox:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
32
39
  @keyframes fdy-combo-in{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:none;}}
@@ -29,7 +29,20 @@
29
29
 
30
30
  /* Calendar grid */
31
31
  .fdy-cal__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);}
32
- .fdy-cal__title{flex:1;text-align:center;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;}
32
+ /* The title is a CONTROL now (#004 §1): it drills to the month grid, so it needs the UA button box
33
+ reset and the same hover/focus treatment as the arrows beside it. */
34
+ .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);text-align:center;font-family:inherit;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;cursor:pointer;}
35
+ .fdy-cal__title:hover{background:var(--color-surface-2);}
36
+ .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
37
+ /* Month grid — 3×4 over the same track width as the 7-column day grid, so the panel does not resize
38
+ when you drill and the pointer does not have to travel. */
39
+ .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
40
+ .fdy-cal__month{display:inline-flex;align-items:center;justify-content:center;height:2.25rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-family:inherit;font-size:var(--text-sm);text-transform:capitalize;cursor:pointer;}
41
+ .fdy-cal__month:hover{background:var(--color-surface-2);}
42
+ .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
43
+ .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
44
+ .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
45
+ .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
33
46
  .fdy-cal__nav{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;flex:none;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-muted);font-size:var(--text-lg);line-height:1;cursor:pointer;}
34
47
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
35
48
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}