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