@cahyo-dimas/freeday 1.35.0 → 1.37.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.37.0] — 2026-08-19
7
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #014).
8
+ ### Added
9
+ - **`.fdy-table__freeze` + `--fdy-freeze-left`** — freeze **any number** of columns, not just the
10
+ first. 1.35.0 froze `tbody th` at a hard-coded `left:0`, which suits a rate matrix's single row
11
+ header and cannot express a wide table with two identity columns: they all landed on 0 and
12
+ stacked. Mark every cell of a frozen column and give that column its offset; the offsets belong
13
+ to the caller because they are sums of *rendered* widths, which CSS cannot compute.
14
+ `.fdy-table__freeze--edge` marks where the frozen block ends.
15
+ - **A bare control in a `.fdy-filterbar` gets `--control-h`.** The bar aligns `flex-end` so
16
+ labelled fields line up on their inputs; a `.fdy-check` / `.fdy-switch` / `.fdy-radio` / lone
17
+ `.fdy-btn` has no label row, so flex-end left it sitting below the input's centre line. Now its
18
+ box ends where an input's box ends and the control centres on that line.
19
+ ### Changed
20
+ - **BREAKING (from 1.35.0): the header freeze moved to `.fdy-table--sticky-head`.** Freezing a row
21
+ needs a vertical scrollport; a table that only freezes columns scrolls with the **page**, where
22
+ `top:0` sticks the header under the viewport edge — over the consuming app's own top bar.
23
+ `.fdy-table--sticky` is now the enabler (separate borders) and each axis is asked for
24
+ explicitly. `tbody th` no longer freezes implicitly; use `.fdy-table__freeze`. 1.35.0 and 1.36.0
25
+ are unpublished, so no released version carried the old contract.
26
+
27
+ ## [1.36.0] — 2026-08-19
28
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #013).
29
+ ### Added
30
+ - **A year grid in the calendar** (vanilla · Vue · React · Blazor). 1.32.0's month grid killed
31
+ one-click-per-month and left one-click-per-year behind it: the month grid's arrows step a year
32
+ each, so 2026 → 1998 was **28 clicks**. The title now drills twice — days → months → years — with
33
+ the arrows following the level shown, and picking a year drops to that year's months. Every level
34
+ above the day grid stays **navigation**: nothing is committed until a day is picked. Year pages
35
+ are aligned (2016–2027, 2028–2039) rather than centred on the year in view, so the pages tile
36
+ instead of sliding over each other. New classes `.fdy-cal__grid--years` / `.fdy-cal__year`; new
37
+ aria-label props `chooseYearLabel`, `prevYearsLabel`, `nextYearsLabel`.
38
+ ### Changed
39
+ - **The month grid's title now drills UP to years rather than back down to days.** Getting back to
40
+ days is what picking a month already does. Nothing in the kit's own tests or in a consuming app's
41
+ helpers clicked the title from the month grid, so this is not expected to be breaking.
42
+ ### Fixed
43
+ - **`freeday-datepicker.js` spoke Indonesian to the user.** Its placeholder and every nav
44
+ `aria-label` were Indonesian, and because `FdyDatepicker.razor` delegates to the vanilla
45
+ enhancer, **every Blazor app inherited them** — while #009's language guard, which scans
46
+ `adapters/` only, passed. The datepicker is English now. The rest of `src/` is NOT audited: see
47
+ improvement note #013 §2, which lists the enhancers still affected.
48
+
6
49
  ## [1.35.0] — 2026-08-19
7
50
  One note from the back-office app (IDU_EMATE_APPL_WEB, #012).
8
51
  ### Added
package/COMPONENTS.md CHANGED
@@ -516,20 +516,27 @@ Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author
516
516
  — the enhancer builds everything.
517
517
 
518
518
  - `.fdy-datepicker` (+`--error`) · `__trigger` `__value` (+`--placeholder`) `__icon` `__clear`
519
- `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` (+`--months`) `__dow`
520
- `__day` `__month`
519
+ `__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` (+`--months` `--years`)
520
+ `__dow` `__day` `__month` `__year`
521
521
  - Range: wrap two pickers in `.fdy-daterange` + `data-fdy-daterange` (`role="group"`), children
522
522
  get `data-role="from"` / `"to"`; separator `.fdy-daterange__sep`. The end can't precede the start.
523
523
  **Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor) — use it in those stacks.
524
- **Getting to a distant month.** The title is a **button**: press it and the day grid becomes a
525
- 12-cell month grid (`.fdy-cal__grid--months` of `.fdy-cal__month`) for the current year, where the
526
- arrows step **years** instead of months. Pick a
527
- month and you are back on days — choosing a month is navigation, so nothing is committed until a day
528
- is selected. From August 2026 to March 2022 that is 7 clicks; walking month by month is 53.
524
+ **Getting to a distant date.** The title is a **button**, and it drills up one level each press:
525
+ day grid → 12-cell month grid (`.fdy-cal__grid--months` of `.fdy-cal__month`) 12-cell year grid
526
+ (`.fdy-cal__grid--years` of `.fdy-cal__year`). The arrows follow the level shown months, then
527
+ years, then pages of twelve years. Picking a year drops to that year's months, picking a month
528
+ drops to its days: every level above the day grid is **navigation**, so nothing is committed until a
529
+ day is selected. From the year grid the title steps back down to months.
530
+
531
+ Year pages are **aligned**, not centred on the year in view — 2016–2027, then 2028–2039 — so the
532
+ pages tile and a given year always sits in the same place. From August 2026 to March 2022 that is 7
533
+ clicks; walking month by month is 53. To 1998 it is 8; walking is 336.
529
534
 
530
535
  Keyboard, in the day grid: arrows move a day, `Home`/`End` the week, `PageUp`/`PageDown` a month,
531
536
  and **`Shift`+`PageUp`/`PageDown` a year** (WAI-ARIA APG). The month grid mirrors it: arrows ±1/±3
532
- months, `Home`/`End` January/December, `PageUp`/`PageDown` a year, `Enter`/`Space` to choose.
537
+ months, `Home`/`End` January/December, `PageUp`/`PageDown` a year, `Enter`/`Space` to choose. The
538
+ year grid likewise: arrows ±1/±3 years, `Home`/`End` the ends of the page, `PageUp`/`PageDown` a
539
+ page of twelve.
533
540
 
534
541
  - Attributes: `data-value="YYYY-MM-DD"`, `data-placeholder`, `data-label`, `data-fdy-no-icon`
535
542
 
@@ -632,7 +639,11 @@ Responsive two-column document header. Children are `.fdy-field`; `.fdy-field--f
632
639
  ## Filter bar — `.fdy-filterbar`
633
640
  A consistent filter row of `.fdy-field`s with a width rhythm (`--w-sm` · default · `--w-lg` ·
634
641
  `--w-xl` · `--w-2xl`), one `--w-grow` field (usually search) absorbing the slack, and
635
- `.fdy-filterbar__actions` pinned at the end. `--actions-inline` keeps the actions on the control
642
+ `.fdy-filterbar__actions` pinned at the end. A control with **no label of its own** (a
643
+ `.fdy-check`, `.fdy-switch`, `.fdy-radio`, or a lone `.fdy-btn`) is given `--control-h` so it
644
+ centres on the input line instead of hanging below it — the bar aligns `flex-end` to line up
645
+ labelled fields, which would otherwise drop a 20px checkbox below a 32px input's centre.
646
+ `--actions-inline` keeps the actions on the control
636
647
  line. Zero JS. Composes with `.fdy-table-toolbar` on a shared baseline.
637
648
 
638
649
  ## Slider — `.fdy-slider`
@@ -688,20 +699,36 @@ Semantic static table. Wrap in `.fdy-table-wrap` (bordered surface) or `.fdy-tab
688
699
  ```
689
700
 
690
701
  **Frozen axes — `.fdy-table--sticky`.** For a grid read against two axes at once (a rate
691
- matrix, a timetable). Put `.fdy-table-scroll--frozen` on the wrapper it scrolls both ways
692
- and is the scrollport the frozen cells stick to and size it with `--fdy-table-frozen-h`
693
- (default `30rem`). `<thead>` freezes at the top, `<th scope="row">` freezes at the left, and
694
- the first header cell is the corner. Do not reach for `position:sticky` directly: the base
695
- table collapses its borders, and a collapsed border belongs to the table rather than the
696
- cell, so it scrolls out from under whatever you froze.
702
+ matrix, a timetable) or a wide one whose identity columns must stay visible. `.fdy-table--sticky`
703
+ is the enabler: it separates the borders, because a collapsed border belongs to the table rather
704
+ than the cell and scrolls out from under whatever you froze. Then ask for the axes you have:
705
+
706
+ - **Header row** add `.fdy-table--sticky-head`, and put `.fdy-table-scroll--frozen` on the
707
+ wrapper (it scrolls both ways and is the scrollport the freeze resolves against; size it with
708
+ `--fdy-table-frozen-h`, default `30rem`). It is a separate modifier because a table that only
709
+ freezes columns scrolls with the **page**, and `top:0` against the page sticks the header under
710
+ the viewport edge — over the app's own top bar.
711
+ - **Columns** — put `.fdy-table__freeze` on **every cell of the column, header included**, and give
712
+ each frozen column its own `--fdy-freeze-left`: `0` for the first, then the summed widths of the
713
+ frozen columns to its left. Mark the last one `.fdy-table__freeze--edge` so the reader can see
714
+ where the frozen block ends. One frozen column needs no variable at all.
715
+
716
+ The offsets are the caller's job because they depend on rendered column widths, which CSS cannot
717
+ sum — measure them once after render (and on resize) and set the variable.
697
718
 
698
719
  ```html
699
720
  <div class="fdy-table-scroll fdy-table-scroll--frozen" style="--fdy-table-frozen-h:26rem">
700
- <table class="fdy-table fdy-table--sticky">
721
+ <table class="fdy-table fdy-table--sticky fdy-table--sticky-head">
701
722
  <caption class="fdy-visually-hidden">Exchange rates, August 2026</caption>
702
- <thead><tr><th scope="col">Date</th><th scope="col" class="fdy-table__num">USD</th></tr></thead>
723
+ <thead><tr>
724
+ <th scope="col" class="fdy-table__freeze fdy-table__freeze--edge">Date</th>
725
+ <th scope="col" class="fdy-table__num">USD</th>
726
+ </tr></thead>
703
727
  <tbody>
704
- <tr><th scope="row">1 Aug</th><td class="fdy-table__num">16,240</td></tr>
728
+ <tr>
729
+ <th scope="row" class="fdy-table__freeze fdy-table__freeze--edge">1 Aug</th>
730
+ <td class="fdy-table__num">16,240</td>
731
+ </tr>
705
732
  </tbody>
706
733
  </table>
707
734
  </div>
@@ -21,6 +21,14 @@ interface DayCell {
21
21
  focusable: boolean;
22
22
  }
23
23
 
24
+ interface YearCell {
25
+ year: number;
26
+ selected: boolean;
27
+ today: boolean;
28
+ disabled: boolean;
29
+ focusable: boolean;
30
+ }
31
+
24
32
  interface MonthCell {
25
33
  index: number;
26
34
  label: string;
@@ -53,6 +61,11 @@ export interface FdyDatepickerProps {
53
61
  /** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
54
62
  prevYearLabel?: string;
55
63
  nextYearLabel?: string;
64
+ /** aria-label for the title button that drills from the month grid to the year grid. Default 'Choose year'. */
65
+ chooseYearLabel?: string;
66
+ /** aria-labels for the page arrows shown in the year grid. Defaults 'Previous years' / 'Next years'. */
67
+ prevYearsLabel?: string;
68
+ nextYearsLabel?: string;
56
69
  prevMonthLabel?: string;
57
70
  /** aria-label for the next-month nav button. Default 'Next month'. */
58
71
  nextMonthLabel?: string;
@@ -121,10 +134,19 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
121
134
  const [open, setOpen] = useState<boolean>(false);
122
135
  /* 'days' | 'months' — the calendar drills one level up instead of growing furniture beside the
123
136
  title. Before this the only pointer route to another month was one click per month. */
124
- const [mode, setMode] = useState<'days' | 'months'>('days');
137
+ const [mode, setMode] = useState<'days' | 'months' | 'years'>('days');
125
138
  const [focusMonth, setFocusMonth] = useState<number>(0);
139
+ const [focusYear, setFocusYear] = useState<number>(0);
140
+
141
+ /* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring on the year in
142
+ view would make the arrows land on overlapping windows, and the same year would sit somewhere
143
+ different every step. */
144
+ const YEARS_PER_PAGE = 12;
145
+ const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
126
146
  const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
127
147
  const pendingMonthFocusRef = useRef<number | null>(null);
148
+ const yearRefs = useRef<Record<number, HTMLButtonElement | null>>({});
149
+ const pendingYearFocusRef = useRef<number | null>(null);
128
150
 
129
151
  // Render the calendar panel in the top layer (popover) so it never gets clipped by a card or
130
152
  // scroll container; positions against the trigger.
@@ -146,6 +168,9 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
146
168
  const chooseMonthLabelText: string = props.chooseMonthLabel ?? 'Choose month';
147
169
  const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
148
170
  const nextYearLabelText: string = props.nextYearLabel ?? 'Next year';
171
+ const chooseYearLabelText: string = props.chooseYearLabel ?? 'Choose year';
172
+ const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
173
+ const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
149
174
  const monthCellFmt: Intl.DateTimeFormat = useMemo(
150
175
  (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
151
176
  [props.locale],
@@ -271,6 +296,13 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
271
296
  monthRefs.current[index]?.focus();
272
297
  });
273
298
 
299
+ useEffect((): void => {
300
+ if (pendingYearFocusRef.current === null) return;
301
+ const year: number = pendingYearFocusRef.current;
302
+ pendingYearFocusRef.current = null;
303
+ yearRefs.current[year]?.focus();
304
+ });
305
+
274
306
  function openMonthGrid(): void {
275
307
  setFocusMonth(viewMonth.getMonth());
276
308
  setMode('months');
@@ -296,6 +328,73 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
296
328
  pendingMonthFocusRef.current = next;
297
329
  }
298
330
 
331
+ /** A year is unreachable only when EVERY month in it falls outside min/max. */
332
+ function isYearDisabled(year: number): boolean {
333
+ if (minDate !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate).getTime()) return true;
334
+ if (maxDate !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate).getTime()) return true;
335
+ return false;
336
+ }
337
+
338
+ const yearPage: number = yearPageStart(focusYear);
339
+ const yearRangeLabel: string = `${yearPage} – ${yearPage + YEARS_PER_PAGE - 1}`;
340
+
341
+ const yearCells: YearCell[] = useMemo((): YearCell[] => {
342
+ const start: number = yearPageStart(focusYear);
343
+ const thisYear: number = new Date().getFullYear();
344
+ const sel: Date | null = parseISO(props.value);
345
+ return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
346
+ const year: number = start + index;
347
+ return {
348
+ year,
349
+ selected: sel !== null && sel.getFullYear() === year,
350
+ today: thisYear === year,
351
+ disabled: isYearDisabled(year),
352
+ focusable: year === focusYear,
353
+ };
354
+ });
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps
356
+ }, [focusYear, props.value, minDate, maxDate]);
357
+
358
+ function openYearGrid(): void {
359
+ setFocusYear(viewMonth.getFullYear());
360
+ setMode('years');
361
+ pendingYearFocusRef.current = viewMonth.getFullYear();
362
+ }
363
+
364
+ /* Picking a year is NAVIGATION, exactly like picking a month: it drops to that year's month grid
365
+ and commits nothing. */
366
+ function openYear(year: number): void {
367
+ setViewMonth(new Date(year, viewMonth.getMonth(), 1));
368
+ setFocusMonth(viewMonth.getMonth());
369
+ setMode('months');
370
+ pendingMonthFocusRef.current = viewMonth.getMonth();
371
+ }
372
+
373
+ function moveYearFocus(year: number): void {
374
+ setFocusYear(year);
375
+ setViewMonth(new Date(year, viewMonth.getMonth(), 1));
376
+ pendingYearFocusRef.current = year;
377
+ }
378
+
379
+ function onYearKeydown(e: React.KeyboardEvent): void {
380
+ let handled: boolean = true;
381
+ switch (e.key) {
382
+ case 'ArrowLeft': moveYearFocus(focusYear - 1); break;
383
+ case 'ArrowRight': moveYearFocus(focusYear + 1); break;
384
+ case 'ArrowUp': moveYearFocus(focusYear - 3); break;
385
+ case 'ArrowDown': moveYearFocus(focusYear + 3); break;
386
+ case 'Home': moveYearFocus(yearPageStart(focusYear)); break;
387
+ case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
388
+ case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
389
+ case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
390
+ case 'Enter':
391
+ case ' ': openYear(focusYear); break;
392
+ case 'Escape': closePanel(true); break;
393
+ default: handled = false;
394
+ }
395
+ if (handled) { e.preventDefault(); e.stopPropagation(); }
396
+ }
397
+
299
398
  function onMonthKeydown(e: React.KeyboardEvent): void {
300
399
  let handled: boolean = true;
301
400
  switch (e.key) {
@@ -476,8 +575,12 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
476
575
  <button
477
576
  type="button"
478
577
  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)); }}
578
+ aria-label={mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText}
579
+ onClick={(): void => {
580
+ if (mode === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
581
+ else if (mode === 'months') moveMonthFocus(focusMonth, -1);
582
+ else setViewMonth(addMonths(viewMonth, -1));
583
+ }}
481
584
  >
482
585
  &lsaquo;
483
586
  </button>
@@ -485,21 +588,44 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
485
588
  id={titleId}
486
589
  type="button"
487
590
  className="fdy-cal__title"
488
- aria-label={mode === 'months' ? undefined : chooseMonthLabelText}
489
- onClick={(): void => { if (mode === 'months') setMode('days'); else openMonthGrid(); }}
591
+ aria-label={mode === 'years' ? undefined : mode === 'months' ? chooseYearLabelText : chooseMonthLabelText}
592
+ onClick={(): void => { if (mode === 'months') openYearGrid(); else openMonthGrid(); }}
490
593
  >
491
- {mode === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
594
+ {mode === 'years' ? yearRangeLabel : mode === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
492
595
  </button>
493
596
  <button
494
597
  type="button"
495
598
  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)); }}
599
+ aria-label={mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText}
600
+ onClick={(): void => {
601
+ if (mode === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
602
+ else if (mode === 'months') moveMonthFocus(focusMonth, 1);
603
+ else setViewMonth(addMonths(viewMonth, 1));
604
+ }}
498
605
  >
499
606
  &rsaquo;
500
607
  </button>
501
608
  </div>
502
- {mode === 'months' ? (
609
+ {mode === 'years' ? (
610
+ <div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
611
+ {yearCells.map((cell: YearCell) => (
612
+ <button
613
+ key={cell.year}
614
+ ref={(el: HTMLButtonElement | null): void => { yearRefs.current[cell.year] = el; }}
615
+ type="button"
616
+ className={['fdy-cal__year', cell.today ? 'is-today' : '', cell.selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
617
+ role="gridcell"
618
+ aria-label={String(cell.year)}
619
+ aria-selected={cell.selected ? true : undefined}
620
+ disabled={cell.disabled}
621
+ tabIndex={cell.focusable ? 0 : -1}
622
+ onClick={(): void => openYear(cell.year)}
623
+ >
624
+ {cell.year}
625
+ </button>
626
+ ))}
627
+ </div>
628
+ ) : mode === 'months' ? (
503
629
  <div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
504
630
  {monthCells.map((cell: MonthCell) => (
505
631
  <button
@@ -50,6 +50,11 @@ const props = defineProps<{
50
50
  /** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
51
51
  prevYearLabel?: string;
52
52
  nextYearLabel?: string;
53
+ /** aria-label for the title button that drills from the month grid to the year grid. Default 'Choose year'. */
54
+ chooseYearLabel?: string;
55
+ /** aria-labels for the page arrows shown in the year grid. Defaults 'Previous years' / 'Next years'. */
56
+ prevYearsLabel?: string;
57
+ nextYearsLabel?: string;
53
58
  }>();
54
59
 
55
60
  const emit = defineEmits<{
@@ -124,6 +129,9 @@ const clearLabelText: ComputedRef<string> = computed((): string => props.clearLa
124
129
  const chooseMonthLabelText: ComputedRef<string> = computed((): string => props.chooseMonthLabel ?? 'Choose month');
125
130
  const prevYearLabelText: ComputedRef<string> = computed((): string => props.prevYearLabel ?? 'Previous year');
126
131
  const nextYearLabelText: ComputedRef<string> = computed((): string => props.nextYearLabel ?? 'Next year');
132
+ const chooseYearLabelText: ComputedRef<string> = computed((): string => props.chooseYearLabel ?? 'Choose year');
133
+ const prevYearsLabelText: ComputedRef<string> = computed((): string => props.prevYearsLabel ?? 'Previous years');
134
+ const nextYearsLabelText: ComputedRef<string> = computed((): string => props.nextYearsLabel ?? 'Next years');
127
135
 
128
136
  const view: Ref<Date> = ref(startOfMonth(selectedDate.value ?? new Date()));
129
137
  const focusDate: Ref<Date> = ref(selectedDate.value ?? new Date());
@@ -140,8 +148,15 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
140
148
  /* 'days' | 'months' — the calendar drills one level up instead of growing furniture beside the title.
141
149
  Before this the only pointer route to another month was one click per month: August 2026 to March
142
150
  2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all. */
143
- const mode: Ref<'days' | 'months'> = ref('days');
151
+ const mode: Ref<'days' | 'months' | 'years'> = ref('days');
144
152
  const focusMonth: Ref<number> = ref(0);
153
+ const focusYear: Ref<number> = ref(0);
154
+
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;
145
160
 
146
161
  const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
147
162
  (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
@@ -189,6 +204,44 @@ const monthCells: ComputedRef<MonthCell[]> = computed((): MonthCell[] => {
189
204
  });
190
205
  });
191
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
+ }
222
+
223
+ const yearPage: ComputedRef<number> = computed((): number => yearPageStart(focusYear.value));
224
+
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
+ });
240
+
241
+ const yearRangeLabel: ComputedRef<string> = computed(
242
+ (): string => `${yearPage.value} – ${yearPage.value + YEARS_PER_PAGE - 1}`,
243
+ );
244
+
192
245
  const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
193
246
  (): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
194
247
  );
@@ -278,6 +331,51 @@ function openMonth(index: number): void {
278
331
  void nextTick((): void => focusCell());
279
332
  }
280
333
 
334
+ function openYearGrid(): void {
335
+ focusYear.value = view.value.getFullYear();
336
+ mode.value = 'years';
337
+ void nextTick((): void => focusYearCell());
338
+ }
339
+
340
+ function focusYearCell(): void {
341
+ const cell: HTMLElement | null = panelEl.value?.querySelector('.fdy-cal__year[tabindex="0"]') ?? null;
342
+ cell?.focus();
343
+ }
344
+
345
+ /* Picking a year is NAVIGATION, exactly like picking a month: it drops to that year's month grid
346
+ and commits nothing. */
347
+ function openYear(year: number): void {
348
+ view.value = new Date(year, view.value.getMonth(), 1);
349
+ focusMonth.value = view.value.getMonth();
350
+ mode.value = 'months';
351
+ void nextTick((): void => focusMonthCell());
352
+ }
353
+
354
+ function moveYearFocus(year: number): void {
355
+ focusYear.value = year;
356
+ view.value = new Date(year, view.value.getMonth(), 1);
357
+ void nextTick((): void => focusYearCell());
358
+ }
359
+
360
+ 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(); }
377
+ }
378
+
281
379
  function moveMonthFocus(index: number, yearDelta: number): void {
282
380
  if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
283
381
  focusMonth.value = (index + 12) % 12;
@@ -465,24 +563,51 @@ onBeforeUnmount((): void => {
465
563
  <button
466
564
  type="button"
467
565
  class="fdy-cal__nav"
468
- :aria-label="mode === 'months' ? prevYearLabelText : prevMonthLabelText"
469
- @click="mode === 'months' ? moveMonthFocus(focusMonth, -1) : (view = addMonths(view, -1))"
566
+ :aria-label="mode === 'years' ? prevYearsLabelText : mode === 'months' ? prevYearLabelText : prevMonthLabelText"
567
+ @click="
568
+ mode === 'years'
569
+ ? moveYearFocus(focusYear - 12)
570
+ : mode === 'months'
571
+ ? moveMonthFocus(focusMonth, -1)
572
+ : (view = addMonths(view, -1))
573
+ "
470
574
  >‹</button>
471
575
  <button
472
576
  :id="titleId"
473
577
  type="button"
474
578
  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>
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>
478
582
  <button
479
583
  type="button"
480
584
  class="fdy-cal__nav"
481
- :aria-label="mode === 'months' ? nextYearLabelText : nextMonthLabelText"
482
- @click="mode === 'months' ? moveMonthFocus(focusMonth, 1) : (view = addMonths(view, 1))"
585
+ :aria-label="mode === 'years' ? nextYearsLabelText : mode === 'months' ? nextYearLabelText : nextMonthLabelText"
586
+ @click="
587
+ mode === 'years'
588
+ ? moveYearFocus(focusYear + 12)
589
+ : mode === 'months'
590
+ ? moveMonthFocus(focusMonth, 1)
591
+ : (view = addMonths(view, 1))
592
+ "
483
593
  >›</button>
484
594
  </div>
485
- <div v-if="mode === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
595
+ <div v-if="mode === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
596
+ <button
597
+ v-for="cell in yearCells"
598
+ :key="cell.year"
599
+ type="button"
600
+ class="fdy-cal__year"
601
+ :class="{ 'is-today': cell.today, 'is-selected': cell.selected }"
602
+ role="gridcell"
603
+ :aria-label="String(cell.year)"
604
+ :aria-selected="cell.selected ? 'true' : undefined"
605
+ :disabled="cell.disabled"
606
+ :tabindex="cell.focusable ? 0 : -1"
607
+ @click="openYear(cell.year)"
608
+ >{{ cell.year }}</button>
609
+ </div>
610
+ <div v-else-if="mode === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
486
611
  <button
487
612
  v-for="cell in monthCells"
488
613
  :key="cell.index"
@@ -57,8 +57,8 @@
57
57
  wrap.dataset.fdyDpReady = '1';
58
58
  wrap.classList.add('fdy-datepicker');
59
59
 
60
- var placeholder = wrap.getAttribute('data-placeholder') || 'Pilih tanggal';
61
- var label = wrap.getAttribute('data-label') || 'Tanggal';
60
+ var placeholder = wrap.getAttribute('data-placeholder') || 'Choose a date';
61
+ var label = wrap.getAttribute('data-label') || 'Date';
62
62
  var selected = parseISO(wrap.getAttribute('data-value'));
63
63
  var minDate = parseISO(wrap.getAttribute('data-min'));
64
64
  var maxDate = parseISO(wrap.getAttribute('data-max'));
@@ -73,6 +73,7 @@
73
73
  month" twenty-four times to reach a date two years back. */
74
74
  var mode = 'days';
75
75
  var focusMonth = null;
76
+ var focusYear = null;
76
77
 
77
78
  var trigger = document.createElement('button');
78
79
  trigger.type = 'button';
@@ -150,20 +151,21 @@
150
151
  }
151
152
 
152
153
  function render() {
154
+ if (mode === 'years') { renderYears(); return; }
153
155
  if (mode === 'months') { renderMonths(); return; }
154
156
  panel.innerHTML = '';
155
157
  var head = document.createElement('div');
156
158
  head.className = 'fdy-cal__head';
157
- var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', pilih bulan', function () {
159
+ var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', choose month', function () {
158
160
  mode = 'months';
159
161
  focusMonth = view.getMonth();
160
162
  render();
161
163
  focusMonthCell();
162
164
  });
163
165
  panel.setAttribute('aria-labelledby', title.id);
164
- head.appendChild(navButton('‹', 'Bulan sebelumnya', function () { view = addMonths(view, -1); render(); }));
166
+ head.appendChild(navButton('‹', 'Previous month', function () { view = addMonths(view, -1); render(); }));
165
167
  head.appendChild(title);
166
- head.appendChild(navButton('›', 'Bulan berikutnya', function () { view = addMonths(view, 1); render(); }));
168
+ head.appendChild(navButton('›', 'Next month', function () { view = addMonths(view, 1); render(); }));
167
169
  panel.appendChild(head);
168
170
 
169
171
  var grid = document.createElement('div');
@@ -220,15 +222,16 @@
220
222
  var year = view.getFullYear();
221
223
  var head = document.createElement('div');
222
224
  head.className = 'fdy-cal__head';
223
- var title = titleButton(String(year), year + ', kembali ke tanggal', function () {
224
- mode = 'days';
225
+ var title = titleButton(String(year), year + ', choose year', function () {
226
+ mode = 'years';
227
+ focusYear = year;
225
228
  render();
226
- focusFocusDate();
229
+ focusYearCell();
227
230
  });
228
231
  panel.setAttribute('aria-labelledby', title.id);
229
- head.appendChild(navButton('‹', 'Tahun sebelumnya', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
232
+ head.appendChild(navButton('‹', 'Previous year', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
230
233
  head.appendChild(title);
231
- head.appendChild(navButton('›', 'Tahun berikutnya', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
234
+ head.appendChild(navButton('›', 'Next year', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
232
235
  panel.appendChild(head);
233
236
 
234
237
  var grid = document.createElement('div');
@@ -259,6 +262,108 @@
259
262
  panel.appendChild(grid);
260
263
  }
261
264
 
265
+ /* A page of twelve years, aligned so pages TILE: 2016-2027, 2028-2039. Anchoring the page on
266
+ the year in view instead would make ‹ and › land on overlapping windows, and the same year
267
+ would appear at a different spot every time you stepped. */
268
+ var YEARS_PER_PAGE = 12;
269
+ function yearPageStart(y) { return Math.floor(y / YEARS_PER_PAGE) * YEARS_PER_PAGE; }
270
+
271
+ /* A year is only unreachable when EVERY month in it falls outside min/max — the same rule
272
+ monthDisabled applies one level down, for the same reason: a bound that sits inside the year
273
+ disables neither end. */
274
+ function yearDisabled(y) {
275
+ if (minDate && new Date(y, 11, 31) < startOfDay(minDate)) return true;
276
+ if (maxDate && new Date(y, 0, 1) > startOfDay(maxDate)) return true;
277
+ return false;
278
+ }
279
+
280
+ function renderYears() {
281
+ panel.innerHTML = '';
282
+ var start = yearPageStart(view.getFullYear());
283
+ var end = start + YEARS_PER_PAGE - 1;
284
+ var head = document.createElement('div');
285
+ head.className = 'fdy-cal__head';
286
+ var title = titleButton(start + ' – ' + end, start + ' to ' + end + ', back to months', function () {
287
+ mode = 'months';
288
+ focusMonth = view.getMonth();
289
+ render();
290
+ focusMonthCell();
291
+ });
292
+ panel.setAttribute('aria-labelledby', title.id);
293
+ head.appendChild(navButton('‹', 'Previous years', function () { moveYearFocus(focusYear - YEARS_PER_PAGE); }));
294
+ head.appendChild(title);
295
+ head.appendChild(navButton('›', 'Next years', function () { moveYearFocus(focusYear + YEARS_PER_PAGE); }));
296
+ panel.appendChild(head);
297
+
298
+ var grid = document.createElement('div');
299
+ grid.className = 'fdy-cal__grid fdy-cal__grid--years';
300
+ grid.setAttribute('role', 'grid');
301
+ grid.setAttribute('aria-labelledby', title.id);
302
+ var thisYear = new Date().getFullYear();
303
+ if (focusYear === null) focusYear = view.getFullYear();
304
+ for (var i = 0; i < YEARS_PER_PAGE; i++) {
305
+ var y = start + i;
306
+ var btn = document.createElement('button');
307
+ btn.type = 'button';
308
+ btn.className = 'fdy-cal__year';
309
+ btn.setAttribute('role', 'gridcell');
310
+ btn.setAttribute('aria-label', String(y));
311
+ btn.textContent = String(y);
312
+ if (y === thisYear) btn.classList.add('is-today');
313
+ if (selected && selected.getFullYear() === y) {
314
+ btn.classList.add('is-selected');
315
+ btn.setAttribute('aria-selected', 'true');
316
+ }
317
+ if (yearDisabled(y)) btn.disabled = true;
318
+ btn.tabIndex = y === focusYear ? 0 : -1;
319
+ btn.addEventListener('click', (function (yy) { return function () { openYear(yy); }; })(y));
320
+ grid.appendChild(btn);
321
+ }
322
+ grid.addEventListener('keydown', onYearKey);
323
+ panel.appendChild(grid);
324
+ }
325
+
326
+ function focusYearCell() {
327
+ var cell = panel.querySelector('.fdy-cal__year[tabindex="0"]');
328
+ if (cell) cell.focus();
329
+ }
330
+
331
+ /* Picking a year is navigation, exactly like picking a month: it drops to the month grid of
332
+ that year. Nothing is committed until a DAY is chosen. */
333
+ function openYear(y) {
334
+ view = new Date(y, view.getMonth(), 1);
335
+ focusMonth = view.getMonth();
336
+ mode = 'months';
337
+ render();
338
+ focusMonthCell();
339
+ }
340
+
341
+ function moveYearFocus(y) {
342
+ focusYear = y;
343
+ view = new Date(y, view.getMonth(), 1);
344
+ render();
345
+ focusYearCell();
346
+ }
347
+
348
+ function onYearKey(e) {
349
+ var handled = true;
350
+ switch (e.key) {
351
+ case 'ArrowLeft': moveYearFocus(focusYear - 1); break;
352
+ case 'ArrowRight': moveYearFocus(focusYear + 1); break;
353
+ case 'ArrowUp': moveYearFocus(focusYear - 3); break;
354
+ case 'ArrowDown': moveYearFocus(focusYear + 3); break;
355
+ case 'Home': moveYearFocus(yearPageStart(focusYear)); break;
356
+ case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
357
+ case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
358
+ case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
359
+ case 'Enter':
360
+ case ' ': openYear(focusYear); break;
361
+ case 'Escape': close(true); break;
362
+ default: handled = false;
363
+ }
364
+ if (handled) { e.preventDefault(); e.stopPropagation(); }
365
+ }
366
+
262
367
  function focusMonthCell() {
263
368
  var cell = panel.querySelector('.fdy-cal__month[tabindex="0"]');
264
369
  if (cell) cell.focus();
@@ -1145,12 +1145,12 @@ a { color: var(--color-primary); }
1145
1145
  .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;margin:0;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;}
1146
1146
  .fdy-cal__title:hover{background:var(--color-surface-2);}
1147
1147
  .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1148
- .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;}
1149
- .fdy-cal__month:hover{background:var(--color-surface-2);}
1150
- .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1151
- .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
1152
- .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
1153
- .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
1148
+ .fdy-cal__month,.fdy-cal__year{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;}
1149
+ .fdy-cal__month:hover,.fdy-cal__year:hover{background:var(--color-surface-2);}
1150
+ .fdy-cal__month:focus-visible,.fdy-cal__year:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
1151
+ .fdy-cal__month:disabled,.fdy-cal__year:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
1152
+ .fdy-cal__month.is-today,.fdy-cal__year.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
1153
+ .fdy-cal__month.is-selected,.fdy-cal__year.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
1154
1154
  .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;}
1155
1155
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
1156
1156
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
@@ -1161,7 +1161,7 @@ a { color: var(--color-primary); }
1161
1161
  AFTER `.fdy-cal__grid`, and that is the whole rule: the element carries both classes, they weigh
1162
1162
  the same, so source order decides. Declared earlier, the modifier lost to the base it modifies and
1163
1163
  twelve months rendered seven across — the layout the comment above described, never once applied. */
1164
- .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
1164
+ .fdy-cal__grid--months,.fdy-cal__grid--years{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
1165
1165
  .fdy-cal__dow{display:flex;align-items:center;justify-content:center;height:1.75rem;font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--color-text-muted);text-transform:capitalize;}
1166
1166
  .fdy-cal__day{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-size:var(--text-sm);font-variant-numeric:tabular-nums;cursor:pointer;}
1167
1167
  .fdy-cal__day:hover{background:var(--color-surface-2);}
@@ -1298,6 +1298,14 @@ a { color: var(--color-primary); }
1298
1298
  * `flex:1 1 16rem` reserves 16rem for line-breaking), NOT its rendered width — so the grow basis,
1299
1299
  * not the visible width, decides the wrap point. Margins can't keep the actions off a lonely wrapped
1300
1300
  * row; to avoid stranded actions, tune the --w-grow basis or drop a field so the bar fits one row. */
1301
+ /* A control that carries NO label of its own — a checkbox, a switch, a lone
1302
+ button — in a bar whose `align-items:flex-end` exists to line up LABELLED
1303
+ fields. Flex-end puts such a control's bottom on the inputs' bottom, so a
1304
+ 20px checkbox sits well below the 32px input's centre line and reads as
1305
+ dropped. Giving it the height of a control lands its box exactly where an
1306
+ input's box ends, and its own centring then puts it on the input's centre
1307
+ line. Raised from IDU_EMATE_APPL_WEB (#014). */
1308
+ .fdy-filterbar>.fdy-check,.fdy-filterbar>.fdy-radio,.fdy-filterbar>.fdy-switch,.fdy-filterbar>.fdy-btn{min-height:var(--control-h);}
1301
1309
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
1302
1310
  /* Opt-in: sit the actions inline right after the last field instead of at the far edge — so when the
1303
1311
  * bar wraps they read as attached to the filters rather than shoved to the right of a lonely row. */
@@ -1667,15 +1675,37 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1667
1675
  back on the cell that sticks. */
1668
1676
  .fdy-table--sticky{border-collapse:separate;border-spacing:0;}
1669
1677
  .fdy-table--sticky th,.fdy-table--sticky td{border-bottom:var(--bw) solid var(--color-border-muted);}
1670
- .fdy-table--sticky thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
1671
- /* The row header freezes sideways over the data cells, so it needs an opaque
1672
- background of its own and it takes the BODY surface, not the header one, so a
1673
- hovered row stays one continuous band across the freeze line. Losing that is
1674
- losing the only way to track a row across a wide grid. */
1675
- .fdy-table--sticky tbody th{position:sticky;left:0;z-index:1;background:var(--color-surface);border-right:var(--bw) solid var(--color-border);}
1676
- .fdy-table--sticky tbody tr:hover th{background:var(--color-surface-2);}
1677
- /* The corner cell is frozen on both axes, so it has to outrank both. */
1678
- .fdy-table--sticky thead th:first-child{left:0;z-index:3;border-right:var(--bw) solid var(--color-border);}
1678
+
1679
+ /* Freezing the HEADER ROW is its own modifier because it needs a vertical
1680
+ scrollport (`.fdy-table-scroll--frozen`). A table that only freezes columns
1681
+ scrolls with the PAGE, and `top:0` against the page sticks the header under
1682
+ the viewport edge — over the app's own top bar which is worse than not
1683
+ freezing it. Ask for the axis you actually have. */
1684
+ .fdy-table--sticky-head thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
1685
+
1686
+ /* Freezing a COLUMN: put `.fdy-table__freeze` on every cell of it, header
1687
+ included, and give each frozen column its own `--fdy-freeze-left` — 0 for the
1688
+ first, the summed widths of the frozen columns left of it after that. One
1689
+ frozen column needs no variable at all. Mark the last one `--edge` so the
1690
+ reader can see where the frozen block ends.
1691
+
1692
+ The offsets are the caller's job on purpose: they depend on rendered column
1693
+ widths, which CSS cannot sum, and hard-coding them here would only work for
1694
+ tables whose columns are all the same width. */
1695
+ .fdy-table--sticky .fdy-table__freeze{position:sticky;left:var(--fdy-freeze-left,0);}
1696
+ /* A frozen body cell slides OVER the data, so it must be OPAQUE — otherwise the
1697
+ cells beneath show through it. A `th` already carries the header tint from the
1698
+ base rule, and that tint is usually the whole point: it is what marks a column
1699
+ as identity rather than data. So only a `td` is given a surface here. A table
1700
+ that wants its frozen `th` to read as a body row instead — a rate matrix, where
1701
+ a hovered row should band unbroken across the freeze line — says so itself. */
1702
+ .fdy-table--sticky tbody .fdy-table__freeze{z-index:1;}
1703
+ .fdy-table--sticky tbody td.fdy-table__freeze{background:var(--color-surface);}
1704
+ .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--color-surface-2);}
1705
+ /* A frozen header cell is stuck on both axes wherever the head is frozen too, so
1706
+ it has to outrank both. */
1707
+ .fdy-table--sticky thead .fdy-table__freeze{z-index:3;}
1708
+ .fdy-table--sticky .fdy-table__freeze--edge{border-right:var(--bw) solid var(--color-border);}
1679
1709
 
1680
1710
  /* The scrollport a sticky cell sticks to. Both axes scroll here, and the height is a
1681
1711
  custom property so a page can size it without out-specifying this rule. */
package/dist/freeday.css CHANGED
@@ -762,12 +762,12 @@ a { color: var(--color-primary); }
762
762
  .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;margin:0;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;}
763
763
  .fdy-cal__title:hover{background:var(--color-surface-2);}
764
764
  .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
765
- .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;}
766
- .fdy-cal__month:hover{background:var(--color-surface-2);}
767
- .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
768
- .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
769
- .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
770
- .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
765
+ .fdy-cal__month,.fdy-cal__year{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;}
766
+ .fdy-cal__month:hover,.fdy-cal__year:hover{background:var(--color-surface-2);}
767
+ .fdy-cal__month:focus-visible,.fdy-cal__year:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
768
+ .fdy-cal__month:disabled,.fdy-cal__year:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
769
+ .fdy-cal__month.is-today,.fdy-cal__year.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
770
+ .fdy-cal__month.is-selected,.fdy-cal__year.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
771
771
  .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;}
772
772
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
773
773
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
@@ -778,7 +778,7 @@ a { color: var(--color-primary); }
778
778
  AFTER `.fdy-cal__grid`, and that is the whole rule: the element carries both classes, they weigh
779
779
  the same, so source order decides. Declared earlier, the modifier lost to the base it modifies and
780
780
  twelve months rendered seven across — the layout the comment above described, never once applied. */
781
- .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
781
+ .fdy-cal__grid--months,.fdy-cal__grid--years{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
782
782
  .fdy-cal__dow{display:flex;align-items:center;justify-content:center;height:1.75rem;font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--color-text-muted);text-transform:capitalize;}
783
783
  .fdy-cal__day{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-size:var(--text-sm);font-variant-numeric:tabular-nums;cursor:pointer;}
784
784
  .fdy-cal__day:hover{background:var(--color-surface-2);}
@@ -915,6 +915,14 @@ a { color: var(--color-primary); }
915
915
  * `flex:1 1 16rem` reserves 16rem for line-breaking), NOT its rendered width — so the grow basis,
916
916
  * not the visible width, decides the wrap point. Margins can't keep the actions off a lonely wrapped
917
917
  * row; to avoid stranded actions, tune the --w-grow basis or drop a field so the bar fits one row. */
918
+ /* A control that carries NO label of its own — a checkbox, a switch, a lone
919
+ button — in a bar whose `align-items:flex-end` exists to line up LABELLED
920
+ fields. Flex-end puts such a control's bottom on the inputs' bottom, so a
921
+ 20px checkbox sits well below the 32px input's centre line and reads as
922
+ dropped. Giving it the height of a control lands its box exactly where an
923
+ input's box ends, and its own centring then puts it on the input's centre
924
+ line. Raised from IDU_EMATE_APPL_WEB (#014). */
925
+ .fdy-filterbar>.fdy-check,.fdy-filterbar>.fdy-radio,.fdy-filterbar>.fdy-switch,.fdy-filterbar>.fdy-btn{min-height:var(--control-h);}
918
926
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
919
927
  /* Opt-in: sit the actions inline right after the last field instead of at the far edge — so when the
920
928
  * bar wraps they read as attached to the filters rather than shoved to the right of a lonely row. */
@@ -1284,15 +1292,37 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1284
1292
  back on the cell that sticks. */
1285
1293
  .fdy-table--sticky{border-collapse:separate;border-spacing:0;}
1286
1294
  .fdy-table--sticky th,.fdy-table--sticky td{border-bottom:var(--bw) solid var(--color-border-muted);}
1287
- .fdy-table--sticky thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
1288
- /* The row header freezes sideways over the data cells, so it needs an opaque
1289
- background of its own and it takes the BODY surface, not the header one, so a
1290
- hovered row stays one continuous band across the freeze line. Losing that is
1291
- losing the only way to track a row across a wide grid. */
1292
- .fdy-table--sticky tbody th{position:sticky;left:0;z-index:1;background:var(--color-surface);border-right:var(--bw) solid var(--color-border);}
1293
- .fdy-table--sticky tbody tr:hover th{background:var(--color-surface-2);}
1294
- /* The corner cell is frozen on both axes, so it has to outrank both. */
1295
- .fdy-table--sticky thead th:first-child{left:0;z-index:3;border-right:var(--bw) solid var(--color-border);}
1295
+
1296
+ /* Freezing the HEADER ROW is its own modifier because it needs a vertical
1297
+ scrollport (`.fdy-table-scroll--frozen`). A table that only freezes columns
1298
+ scrolls with the PAGE, and `top:0` against the page sticks the header under
1299
+ the viewport edge — over the app's own top bar which is worse than not
1300
+ freezing it. Ask for the axis you actually have. */
1301
+ .fdy-table--sticky-head thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
1302
+
1303
+ /* Freezing a COLUMN: put `.fdy-table__freeze` on every cell of it, header
1304
+ included, and give each frozen column its own `--fdy-freeze-left` — 0 for the
1305
+ first, the summed widths of the frozen columns left of it after that. One
1306
+ frozen column needs no variable at all. Mark the last one `--edge` so the
1307
+ reader can see where the frozen block ends.
1308
+
1309
+ The offsets are the caller's job on purpose: they depend on rendered column
1310
+ widths, which CSS cannot sum, and hard-coding them here would only work for
1311
+ tables whose columns are all the same width. */
1312
+ .fdy-table--sticky .fdy-table__freeze{position:sticky;left:var(--fdy-freeze-left,0);}
1313
+ /* A frozen body cell slides OVER the data, so it must be OPAQUE — otherwise the
1314
+ cells beneath show through it. A `th` already carries the header tint from the
1315
+ base rule, and that tint is usually the whole point: it is what marks a column
1316
+ as identity rather than data. So only a `td` is given a surface here. A table
1317
+ that wants its frozen `th` to read as a body row instead — a rate matrix, where
1318
+ a hovered row should band unbroken across the freeze line — says so itself. */
1319
+ .fdy-table--sticky tbody .fdy-table__freeze{z-index:1;}
1320
+ .fdy-table--sticky tbody td.fdy-table__freeze{background:var(--color-surface);}
1321
+ .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--color-surface-2);}
1322
+ /* A frozen header cell is stuck on both axes wherever the head is frozen too, so
1323
+ it has to outrank both. */
1324
+ .fdy-table--sticky thead .fdy-table__freeze{z-index:3;}
1325
+ .fdy-table--sticky .fdy-table__freeze--edge{border-right:var(--bw) solid var(--color-border);}
1296
1326
 
1297
1327
  /* The scrollport a sticky cell sticks to. Both axes scroll here, and the height is a
1298
1328
  custom property so a page can size it without out-specifying this rule. */
package/dist/freeday.js CHANGED
@@ -1364,8 +1364,8 @@
1364
1364
  wrap.dataset.fdyDpReady = '1';
1365
1365
  wrap.classList.add('fdy-datepicker');
1366
1366
 
1367
- var placeholder = wrap.getAttribute('data-placeholder') || 'Pilih tanggal';
1368
- var label = wrap.getAttribute('data-label') || 'Tanggal';
1367
+ var placeholder = wrap.getAttribute('data-placeholder') || 'Choose a date';
1368
+ var label = wrap.getAttribute('data-label') || 'Date';
1369
1369
  var selected = parseISO(wrap.getAttribute('data-value'));
1370
1370
  var minDate = parseISO(wrap.getAttribute('data-min'));
1371
1371
  var maxDate = parseISO(wrap.getAttribute('data-max'));
@@ -1380,6 +1380,7 @@
1380
1380
  month" twenty-four times to reach a date two years back. */
1381
1381
  var mode = 'days';
1382
1382
  var focusMonth = null;
1383
+ var focusYear = null;
1383
1384
 
1384
1385
  var trigger = document.createElement('button');
1385
1386
  trigger.type = 'button';
@@ -1457,20 +1458,21 @@
1457
1458
  }
1458
1459
 
1459
1460
  function render() {
1461
+ if (mode === 'years') { renderYears(); return; }
1460
1462
  if (mode === 'months') { renderMonths(); return; }
1461
1463
  panel.innerHTML = '';
1462
1464
  var head = document.createElement('div');
1463
1465
  head.className = 'fdy-cal__head';
1464
- var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', pilih bulan', function () {
1466
+ var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', choose month', function () {
1465
1467
  mode = 'months';
1466
1468
  focusMonth = view.getMonth();
1467
1469
  render();
1468
1470
  focusMonthCell();
1469
1471
  });
1470
1472
  panel.setAttribute('aria-labelledby', title.id);
1471
- head.appendChild(navButton('‹', 'Bulan sebelumnya', function () { view = addMonths(view, -1); render(); }));
1473
+ head.appendChild(navButton('‹', 'Previous month', function () { view = addMonths(view, -1); render(); }));
1472
1474
  head.appendChild(title);
1473
- head.appendChild(navButton('›', 'Bulan berikutnya', function () { view = addMonths(view, 1); render(); }));
1475
+ head.appendChild(navButton('›', 'Next month', function () { view = addMonths(view, 1); render(); }));
1474
1476
  panel.appendChild(head);
1475
1477
 
1476
1478
  var grid = document.createElement('div');
@@ -1527,15 +1529,16 @@
1527
1529
  var year = view.getFullYear();
1528
1530
  var head = document.createElement('div');
1529
1531
  head.className = 'fdy-cal__head';
1530
- var title = titleButton(String(year), year + ', kembali ke tanggal', function () {
1531
- mode = 'days';
1532
+ var title = titleButton(String(year), year + ', choose year', function () {
1533
+ mode = 'years';
1534
+ focusYear = year;
1532
1535
  render();
1533
- focusFocusDate();
1536
+ focusYearCell();
1534
1537
  });
1535
1538
  panel.setAttribute('aria-labelledby', title.id);
1536
- head.appendChild(navButton('‹', 'Tahun sebelumnya', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
1539
+ head.appendChild(navButton('‹', 'Previous year', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
1537
1540
  head.appendChild(title);
1538
- head.appendChild(navButton('›', 'Tahun berikutnya', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
1541
+ head.appendChild(navButton('›', 'Next year', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
1539
1542
  panel.appendChild(head);
1540
1543
 
1541
1544
  var grid = document.createElement('div');
@@ -1566,6 +1569,108 @@
1566
1569
  panel.appendChild(grid);
1567
1570
  }
1568
1571
 
1572
+ /* A page of twelve years, aligned so pages TILE: 2016-2027, 2028-2039. Anchoring the page on
1573
+ the year in view instead would make ‹ and › land on overlapping windows, and the same year
1574
+ would appear at a different spot every time you stepped. */
1575
+ var YEARS_PER_PAGE = 12;
1576
+ function yearPageStart(y) { return Math.floor(y / YEARS_PER_PAGE) * YEARS_PER_PAGE; }
1577
+
1578
+ /* A year is only unreachable when EVERY month in it falls outside min/max — the same rule
1579
+ monthDisabled applies one level down, for the same reason: a bound that sits inside the year
1580
+ disables neither end. */
1581
+ function yearDisabled(y) {
1582
+ if (minDate && new Date(y, 11, 31) < startOfDay(minDate)) return true;
1583
+ if (maxDate && new Date(y, 0, 1) > startOfDay(maxDate)) return true;
1584
+ return false;
1585
+ }
1586
+
1587
+ function renderYears() {
1588
+ panel.innerHTML = '';
1589
+ var start = yearPageStart(view.getFullYear());
1590
+ var end = start + YEARS_PER_PAGE - 1;
1591
+ var head = document.createElement('div');
1592
+ head.className = 'fdy-cal__head';
1593
+ var title = titleButton(start + ' – ' + end, start + ' to ' + end + ', back to months', function () {
1594
+ mode = 'months';
1595
+ focusMonth = view.getMonth();
1596
+ render();
1597
+ focusMonthCell();
1598
+ });
1599
+ panel.setAttribute('aria-labelledby', title.id);
1600
+ head.appendChild(navButton('‹', 'Previous years', function () { moveYearFocus(focusYear - YEARS_PER_PAGE); }));
1601
+ head.appendChild(title);
1602
+ head.appendChild(navButton('›', 'Next years', function () { moveYearFocus(focusYear + YEARS_PER_PAGE); }));
1603
+ panel.appendChild(head);
1604
+
1605
+ var grid = document.createElement('div');
1606
+ grid.className = 'fdy-cal__grid fdy-cal__grid--years';
1607
+ grid.setAttribute('role', 'grid');
1608
+ grid.setAttribute('aria-labelledby', title.id);
1609
+ var thisYear = new Date().getFullYear();
1610
+ if (focusYear === null) focusYear = view.getFullYear();
1611
+ for (var i = 0; i < YEARS_PER_PAGE; i++) {
1612
+ var y = start + i;
1613
+ var btn = document.createElement('button');
1614
+ btn.type = 'button';
1615
+ btn.className = 'fdy-cal__year';
1616
+ btn.setAttribute('role', 'gridcell');
1617
+ btn.setAttribute('aria-label', String(y));
1618
+ btn.textContent = String(y);
1619
+ if (y === thisYear) btn.classList.add('is-today');
1620
+ if (selected && selected.getFullYear() === y) {
1621
+ btn.classList.add('is-selected');
1622
+ btn.setAttribute('aria-selected', 'true');
1623
+ }
1624
+ if (yearDisabled(y)) btn.disabled = true;
1625
+ btn.tabIndex = y === focusYear ? 0 : -1;
1626
+ btn.addEventListener('click', (function (yy) { return function () { openYear(yy); }; })(y));
1627
+ grid.appendChild(btn);
1628
+ }
1629
+ grid.addEventListener('keydown', onYearKey);
1630
+ panel.appendChild(grid);
1631
+ }
1632
+
1633
+ function focusYearCell() {
1634
+ var cell = panel.querySelector('.fdy-cal__year[tabindex="0"]');
1635
+ if (cell) cell.focus();
1636
+ }
1637
+
1638
+ /* Picking a year is navigation, exactly like picking a month: it drops to the month grid of
1639
+ that year. Nothing is committed until a DAY is chosen. */
1640
+ function openYear(y) {
1641
+ view = new Date(y, view.getMonth(), 1);
1642
+ focusMonth = view.getMonth();
1643
+ mode = 'months';
1644
+ render();
1645
+ focusMonthCell();
1646
+ }
1647
+
1648
+ function moveYearFocus(y) {
1649
+ focusYear = y;
1650
+ view = new Date(y, view.getMonth(), 1);
1651
+ render();
1652
+ focusYearCell();
1653
+ }
1654
+
1655
+ function onYearKey(e) {
1656
+ var handled = true;
1657
+ switch (e.key) {
1658
+ case 'ArrowLeft': moveYearFocus(focusYear - 1); break;
1659
+ case 'ArrowRight': moveYearFocus(focusYear + 1); break;
1660
+ case 'ArrowUp': moveYearFocus(focusYear - 3); break;
1661
+ case 'ArrowDown': moveYearFocus(focusYear + 3); break;
1662
+ case 'Home': moveYearFocus(yearPageStart(focusYear)); break;
1663
+ case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
1664
+ case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
1665
+ case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
1666
+ case 'Enter':
1667
+ case ' ': openYear(focusYear); break;
1668
+ case 'Escape': close(true); break;
1669
+ default: handled = false;
1670
+ }
1671
+ if (handled) { e.preventDefault(); e.stopPropagation(); }
1672
+ }
1673
+
1569
1674
  function focusMonthCell() {
1570
1675
  var cell = panel.querySelector('.fdy-cal__month[tabindex="0"]');
1571
1676
  if (cell) cell.focus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,12 +34,12 @@
34
34
  .fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;margin:0;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
35
  .fdy-cal__title:hover{background:var(--color-surface-2);}
36
36
  .fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
37
- .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;}
38
- .fdy-cal__month:hover{background:var(--color-surface-2);}
39
- .fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
40
- .fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
41
- .fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
42
- .fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
37
+ .fdy-cal__month,.fdy-cal__year{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;}
38
+ .fdy-cal__month:hover,.fdy-cal__year:hover{background:var(--color-surface-2);}
39
+ .fdy-cal__month:focus-visible,.fdy-cal__year:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
40
+ .fdy-cal__month:disabled,.fdy-cal__year:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
41
+ .fdy-cal__month.is-today,.fdy-cal__year.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
42
+ .fdy-cal__month.is-selected,.fdy-cal__year.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
43
43
  .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;}
44
44
  .fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
45
45
  .fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
@@ -50,7 +50,7 @@
50
50
  AFTER `.fdy-cal__grid`, and that is the whole rule: the element carries both classes, they weigh
51
51
  the same, so source order decides. Declared earlier, the modifier lost to the base it modifies and
52
52
  twelve months rendered seven across — the layout the comment above described, never once applied. */
53
- .fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
53
+ .fdy-cal__grid--months,.fdy-cal__grid--years{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
54
54
  .fdy-cal__dow{display:flex;align-items:center;justify-content:center;height:1.75rem;font-size:var(--text-xs);font-weight:var(--weight-semibold);color:var(--color-text-muted);text-transform:capitalize;}
55
55
  .fdy-cal__day{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-size:var(--text-sm);font-variant-numeric:tabular-nums;cursor:pointer;}
56
56
  .fdy-cal__day:hover{background:var(--color-surface-2);}
@@ -28,6 +28,14 @@
28
28
  * `flex:1 1 16rem` reserves 16rem for line-breaking), NOT its rendered width — so the grow basis,
29
29
  * not the visible width, decides the wrap point. Margins can't keep the actions off a lonely wrapped
30
30
  * row; to avoid stranded actions, tune the --w-grow basis or drop a field so the bar fits one row. */
31
+ /* A control that carries NO label of its own — a checkbox, a switch, a lone
32
+ button — in a bar whose `align-items:flex-end` exists to line up LABELLED
33
+ fields. Flex-end puts such a control's bottom on the inputs' bottom, so a
34
+ 20px checkbox sits well below the 32px input's centre line and reads as
35
+ dropped. Giving it the height of a control lands its box exactly where an
36
+ input's box ends, and its own centring then puts it on the input's centre
37
+ line. Raised from IDU_EMATE_APPL_WEB (#014). */
38
+ .fdy-filterbar>.fdy-check,.fdy-filterbar>.fdy-radio,.fdy-filterbar>.fdy-switch,.fdy-filterbar>.fdy-btn{min-height:var(--control-h);}
31
39
  .fdy-filterbar__actions{display:flex;align-items:center;gap:var(--space-2);margin-left:auto;}
32
40
  /* Opt-in: sit the actions inline right after the last field instead of at the far edge — so when the
33
41
  * bar wraps they read as attached to the filters rather than shoved to the right of a lonely row. */
@@ -47,15 +47,37 @@
47
47
  back on the cell that sticks. */
48
48
  .fdy-table--sticky{border-collapse:separate;border-spacing:0;}
49
49
  .fdy-table--sticky th,.fdy-table--sticky td{border-bottom:var(--bw) solid var(--color-border-muted);}
50
- .fdy-table--sticky thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
51
- /* The row header freezes sideways over the data cells, so it needs an opaque
52
- background of its own and it takes the BODY surface, not the header one, so a
53
- hovered row stays one continuous band across the freeze line. Losing that is
54
- losing the only way to track a row across a wide grid. */
55
- .fdy-table--sticky tbody th{position:sticky;left:0;z-index:1;background:var(--color-surface);border-right:var(--bw) solid var(--color-border);}
56
- .fdy-table--sticky tbody tr:hover th{background:var(--color-surface-2);}
57
- /* The corner cell is frozen on both axes, so it has to outrank both. */
58
- .fdy-table--sticky thead th:first-child{left:0;z-index:3;border-right:var(--bw) solid var(--color-border);}
50
+
51
+ /* Freezing the HEADER ROW is its own modifier because it needs a vertical
52
+ scrollport (`.fdy-table-scroll--frozen`). A table that only freezes columns
53
+ scrolls with the PAGE, and `top:0` against the page sticks the header under
54
+ the viewport edge — over the app's own top bar which is worse than not
55
+ freezing it. Ask for the axis you actually have. */
56
+ .fdy-table--sticky-head thead th{position:sticky;top:0;z-index:2;border-bottom:var(--bw) solid var(--color-border);}
57
+
58
+ /* Freezing a COLUMN: put `.fdy-table__freeze` on every cell of it, header
59
+ included, and give each frozen column its own `--fdy-freeze-left` — 0 for the
60
+ first, the summed widths of the frozen columns left of it after that. One
61
+ frozen column needs no variable at all. Mark the last one `--edge` so the
62
+ reader can see where the frozen block ends.
63
+
64
+ The offsets are the caller's job on purpose: they depend on rendered column
65
+ widths, which CSS cannot sum, and hard-coding them here would only work for
66
+ tables whose columns are all the same width. */
67
+ .fdy-table--sticky .fdy-table__freeze{position:sticky;left:var(--fdy-freeze-left,0);}
68
+ /* A frozen body cell slides OVER the data, so it must be OPAQUE — otherwise the
69
+ cells beneath show through it. A `th` already carries the header tint from the
70
+ base rule, and that tint is usually the whole point: it is what marks a column
71
+ as identity rather than data. So only a `td` is given a surface here. A table
72
+ that wants its frozen `th` to read as a body row instead — a rate matrix, where
73
+ a hovered row should band unbroken across the freeze line — says so itself. */
74
+ .fdy-table--sticky tbody .fdy-table__freeze{z-index:1;}
75
+ .fdy-table--sticky tbody td.fdy-table__freeze{background:var(--color-surface);}
76
+ .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--color-surface-2);}
77
+ /* A frozen header cell is stuck on both axes wherever the head is frozen too, so
78
+ it has to outrank both. */
79
+ .fdy-table--sticky thead .fdy-table__freeze{z-index:3;}
80
+ .fdy-table--sticky .fdy-table__freeze--edge{border-right:var(--bw) solid var(--color-border);}
59
81
 
60
82
  /* The scrollport a sticky cell sticks to. Both axes scroll here, and the height is a
61
83
  custom property so a page can size it without out-specifying this rule. */