@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 +43 -0
- package/COMPONENTS.md +45 -18
- package/adapters/react/components/FdyDatepicker.tsx +135 -9
- package/adapters/vue/components/FdyDatepicker.vue +134 -9
- package/dist/freeday-datepicker.js +115 -10
- package/dist/freeday.bundle.css +46 -16
- package/dist/freeday.css +46 -16
- package/dist/freeday.js +115 -10
- package/package.json +1 -1
- package/src/components/datepicker.css +7 -7
- package/src/components/filterbar.css +8 -0
- package/src/components/table.css +31 -9
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`
|
|
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
|
|
525
|
-
12-cell month grid (`.fdy-cal__grid--months` of `.fdy-cal__month`)
|
|
526
|
-
arrows
|
|
527
|
-
|
|
528
|
-
|
|
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.
|
|
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)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
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
|
|
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 => {
|
|
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
|
‹
|
|
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 === '
|
|
489
|
-
onClick={(): void => { if (mode === 'months')
|
|
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 => {
|
|
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
|
›
|
|
500
607
|
</button>
|
|
501
608
|
</div>
|
|
502
|
-
{mode === '
|
|
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="
|
|
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 === '
|
|
476
|
-
@click="mode === 'months' ? (
|
|
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="
|
|
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 === '
|
|
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') || '
|
|
61
|
-
var label = wrap.getAttribute('data-label') || '
|
|
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) + ',
|
|
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('‹', '
|
|
166
|
+
head.appendChild(navButton('‹', 'Previous month', function () { view = addMonths(view, -1); render(); }));
|
|
165
167
|
head.appendChild(title);
|
|
166
|
-
head.appendChild(navButton('›', '
|
|
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 + ',
|
|
224
|
-
mode = '
|
|
225
|
+
var title = titleButton(String(year), year + ', choose year', function () {
|
|
226
|
+
mode = 'years';
|
|
227
|
+
focusYear = year;
|
|
225
228
|
render();
|
|
226
|
-
|
|
229
|
+
focusYearCell();
|
|
227
230
|
});
|
|
228
231
|
panel.setAttribute('aria-labelledby', title.id);
|
|
229
|
-
head.appendChild(navButton('‹', '
|
|
232
|
+
head.appendChild(navButton('‹', 'Previous year', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
|
|
230
233
|
head.appendChild(title);
|
|
231
|
-
head.appendChild(navButton('›', '
|
|
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();
|
package/dist/freeday.bundle.css
CHANGED
|
@@ -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
|
-
|
|
1671
|
-
/*
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
.
|
|
1676
|
-
.fdy-table--sticky
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
-
|
|
1288
|
-
/*
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
.
|
|
1293
|
-
.fdy-table--sticky
|
|
1294
|
-
|
|
1295
|
-
|
|
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') || '
|
|
1368
|
-
var label = wrap.getAttribute('data-label') || '
|
|
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) + ',
|
|
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('‹', '
|
|
1473
|
+
head.appendChild(navButton('‹', 'Previous month', function () { view = addMonths(view, -1); render(); }));
|
|
1472
1474
|
head.appendChild(title);
|
|
1473
|
-
head.appendChild(navButton('›', '
|
|
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 + ',
|
|
1531
|
-
mode = '
|
|
1532
|
+
var title = titleButton(String(year), year + ', choose year', function () {
|
|
1533
|
+
mode = 'years';
|
|
1534
|
+
focusYear = year;
|
|
1532
1535
|
render();
|
|
1533
|
-
|
|
1536
|
+
focusYearCell();
|
|
1534
1537
|
});
|
|
1535
1538
|
panel.setAttribute('aria-labelledby', title.id);
|
|
1536
|
-
head.appendChild(navButton('‹', '
|
|
1539
|
+
head.appendChild(navButton('‹', 'Previous year', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
|
|
1537
1540
|
head.appendChild(title);
|
|
1538
|
-
head.appendChild(navButton('›', '
|
|
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
|
@@ -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. */
|
package/src/components/table.css
CHANGED
|
@@ -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
|
-
|
|
51
|
-
/*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.
|
|
56
|
-
.fdy-table--sticky
|
|
57
|
-
|
|
58
|
-
|
|
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. */
|