@cahyo-dimas/freeday 1.31.0 → 1.32.1
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 +59 -0
- package/COMPONENTS.md +20 -3
- package/README.id.md +1 -1
- package/README.md +1 -1
- 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 +21 -1
- package/dist/freeday.css +21 -1
- package/dist/freeday.js +126 -10
- package/docs/agent-onboarding.md +23 -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,65 @@
|
|
|
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.1] — 2026-08-18
|
|
7
|
+
Docs only. Asked by a consumer: *"if I upgrade, will the agent in my project know what it gained?"*
|
|
8
|
+
The answer was no, and two reasons why.
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`docs/agent-onboarding.md` never mentioned the changelog.** It is the file a consuming agent
|
|
11
|
+
reads first, and it listed everything in the package except the one file that says what changed
|
|
12
|
+
between the version the project had and the one it now has. It now points at
|
|
13
|
+
`node_modules/@cahyo-dimas/freeday/CHANGELOG.md` explicitly, plus a short *reach for this instead
|
|
14
|
+
of hand-rolling that* table covering the last nine releases — the additions most likely to replace
|
|
15
|
+
something an app already worked around.
|
|
16
|
+
- **Three classes existed only in shorthand** in `COMPONENTS.md` — `.fdy-label--required`,
|
|
17
|
+
`.fdy-cal__month` and `.fdy-cal__grid--months` were written as `(+--required)` and `__month`, so
|
|
18
|
+
the full name appeared **zero** times and a grep for the class found nothing. The drift guard
|
|
19
|
+
cannot see this either: it verifies fully-written names and skips shorthand by design, so a class
|
|
20
|
+
introduced only in shorthand is invisible to both the reader and the test.
|
|
21
|
+
|
|
22
|
+
## [1.32.0] — 2026-08-18
|
|
23
|
+
Improvement note 004, both halves: a calendar you could not steer, and a check mark that talked.
|
|
24
|
+
### Added
|
|
25
|
+
- **The calendar title is a control.** It was a `<div>` — the only thing naming the month, and not
|
|
26
|
+
clickable — so the only pointer route to another month was one click per month: August 2026 to
|
|
27
|
+
March 2022 is **53 clicks**. `Shift`+`PageUp` already jumped a year (APG, correct) but had no
|
|
28
|
+
affordance whatsoever; the reporting repo's own Playwright helper clicked "previous month"
|
|
29
|
+
**twenty-four times** and shipped, which is the strongest possible evidence that nobody finds a
|
|
30
|
+
shortcut nothing mentions.
|
|
31
|
+
Pressing the title now drills to a 12-cell month grid where the arrows step **years**. Same
|
|
32
|
+
journey: **7 clicks**. Picking a month is navigation, not selection — the day grid comes back with
|
|
33
|
+
the roving cell clamped into the new month and nothing is committed until a day is chosen.
|
|
34
|
+
Shipped in the vanilla enhancer, Vue and React; `FdyDateRange` composes the picker in both
|
|
35
|
+
adapters and the Blazor `FdyDatepicker` wraps the enhancer, so all of them inherit it.
|
|
36
|
+
New classes: `.fdy-cal__grid--months`, `.fdy-cal__month`. The month grid mirrors the day grid's
|
|
37
|
+
keyboard contract (arrows ±1/±3, Home/End, PageUp/PageDown a year, Enter/Space to choose), and
|
|
38
|
+
`COMPONENTS.md` now states the `Shift`+`PageUp` shortcut it never mentioned.
|
|
39
|
+
### Fixed
|
|
40
|
+
- **A combo option's accessible name no longer changes when it is selected.** The tick lived inside
|
|
41
|
+
`role="option"` as text, so the selected option announced `✓August` while every other announced
|
|
42
|
+
`August`: the state was read twice — once as `aria-selected`, once as decoration — and
|
|
43
|
+
`getByRole('option', { name: 'August' })` stopped matching the one option that was selected. The
|
|
44
|
+
glyph is now painted by CSS with alt text (`content:"✓" / ""`), the same technique
|
|
45
|
+
`.fdy-label--required` uses. The conditional disappears from all four stacks, and the vanilla
|
|
46
|
+
enhancer's `optionLabel` loses the sibling-walk it needed to skip a glyph.
|
|
47
|
+
### Notes on the shape of the fix
|
|
48
|
+
- **A bug written and caught inside this change:** swapping the calendar head with `v-if`/`v-else`
|
|
49
|
+
destroys the very button the user just pressed, focus falls to `<body>`, and the panel's own
|
|
50
|
+
`focusout` handler closes it mid-navigation. The head is now one element set whose labels and
|
|
51
|
+
handlers switch, and `focusout` ignores a **null** `relatedTarget` — focus lost because an element
|
|
52
|
+
was removed is not focus leaving the control, and a pointer that really lands outside is handled
|
|
53
|
+
by the mousedown path.
|
|
54
|
+
- The report's `aria-hidden` suggestion would also have worked; CSS alt text was chosen because it
|
|
55
|
+
removes the conditional from four implementations instead of adding an attribute to each.
|
|
56
|
+
### Added — guards
|
|
57
|
+
- `browser/harness.mjs` gains **`axName(selector)`** — the accessible name as the *engine* computes
|
|
58
|
+
it (CDP `Accessibility.getPartialAXTree`). This mattered immediately: the first version of the
|
|
59
|
+
name invariant read `textContent`, which never contains CSS generated content, so it passed with
|
|
60
|
+
the tick back in the accessibility tree. With `axName`, that mutation reports `"✓ Badge"`.
|
|
61
|
+
- Real-click drill specs for the vanilla enhancer, Vue and React: the title is a `<button>`, drilling
|
|
62
|
+
shows 12 months and moves focus into the grid, the arrows step years, and picking a month commits
|
|
63
|
+
nothing.
|
|
64
|
+
|
|
6
65
|
## [1.31.0] — 2026-08-18
|
|
7
66
|
Improvement note 002: density was a one-way door.
|
|
8
67
|
### Added
|
package/COMPONENTS.md
CHANGED
|
@@ -307,7 +307,7 @@ native control, otherwise a `<div>` + explicitly associated label.
|
|
|
307
307
|
</fieldset>
|
|
308
308
|
```
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
**`.fdy-label--required` marks the label, not the accessibility tree.** The control already carries
|
|
311
311
|
`required`; the asterisk is painted through `::after` with CSS alt text, so a screen reader never
|
|
312
312
|
reads "star" after the label — something a `<span>` you have to remember to mark `aria-hidden`
|
|
313
313
|
cannot guarantee.
|
|
@@ -403,12 +403,18 @@ Fully styleable dropdown, APG combobox+listbox. Needs `freeday-select.js`.
|
|
|
403
403
|
</button>
|
|
404
404
|
<ul class="fdy-combo__listbox" role="listbox" aria-labelledby="st-l" hidden>
|
|
405
405
|
<li class="fdy-combo__option" role="option" data-value="paid" aria-selected="false"><span class="fdy-combo__check"></span>Paid</li>
|
|
406
|
-
<li class="fdy-combo__option" role="option" data-value="pending" aria-selected="true"><span class="fdy-combo__check"
|
|
406
|
+
<li class="fdy-combo__option" role="option" data-value="pending" aria-selected="true"><span class="fdy-combo__check"></span>Pending</li>
|
|
407
407
|
</ul>
|
|
408
408
|
</div>
|
|
409
409
|
</div>
|
|
410
410
|
```
|
|
411
411
|
|
|
412
|
+
**The selected tick is CSS.** `.fdy-combo__check` is an empty box in the markup; the glyph is painted
|
|
413
|
+
by the stylesheet on `[aria-selected="true"]` with alt text, so it never enters the accessibility
|
|
414
|
+
tree. That keeps an option's accessible name **identical whether or not it is selected** — the state
|
|
415
|
+
is carried by `aria-selected` alone, announced once, and `getByRole('option', { name: 'August' })`
|
|
416
|
+
keeps matching after selection. Do not put a glyph in that span.
|
|
417
|
+
|
|
412
418
|
## Autocomplete — `.fdy-autocomplete`
|
|
413
419
|
> **Typed wrapper: `<FdyAutocomplete>`** — Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
|
|
414
420
|
|
|
@@ -505,10 +511,21 @@ Input-styled trigger + calendar popover. Needs `freeday-datepicker.js`. **Author
|
|
|
505
511
|
— the enhancer builds everything.
|
|
506
512
|
|
|
507
513
|
- `.fdy-datepicker` (+`--error`) · `__trigger` `__value` (+`--placeholder`) `__icon` `__clear`
|
|
508
|
-
`__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` `
|
|
514
|
+
`__panel`; calendar internals `.fdy-cal__head` `__nav` `__title` `__grid` (+`--months`) `__dow`
|
|
515
|
+
`__day` `__month`
|
|
509
516
|
- Range: wrap two pickers in `.fdy-daterange` + `data-fdy-daterange` (`role="group"`), children
|
|
510
517
|
get `data-role="from"` / `"to"`; separator `.fdy-daterange__sep`. The end can't precede the start.
|
|
511
518
|
**Typed wrapper: `<FdyDateRange>`** (Vue/React/Blazor) — use it in those stacks.
|
|
519
|
+
**Getting to a distant month.** The title is a **button**: press it and the day grid becomes a
|
|
520
|
+
12-cell month grid (`.fdy-cal__grid--months` of `.fdy-cal__month`) for the current year, where the
|
|
521
|
+
arrows step **years** instead of months. Pick a
|
|
522
|
+
month and you are back on days — choosing a month is navigation, so nothing is committed until a day
|
|
523
|
+
is selected. From August 2026 to March 2022 that is 7 clicks; walking month by month is 53.
|
|
524
|
+
|
|
525
|
+
Keyboard, in the day grid: arrows move a day, `Home`/`End` the week, `PageUp`/`PageDown` a month,
|
|
526
|
+
and **`Shift`+`PageUp`/`PageDown` a year** (WAI-ARIA APG). The month grid mirrors it: arrows ±1/±3
|
|
527
|
+
months, `Home`/`End` January/December, `PageUp`/`PageDown` a year, `Enter`/`Space` to choose.
|
|
528
|
+
|
|
512
529
|
- Attributes: `data-value="YYYY-MM-DD"`, `data-placeholder`, `data-label`, `data-fdy-no-icon`
|
|
513
530
|
|
|
514
531
|
```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.1)
|
|
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.1)
|
|
9
9
|
|
|
10
10
|
A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
|
|
11
11
|
spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
bool selected = Equals(opt.Value, Value);
|
|
22
22
|
<li class="fdy-combo__option" role="option" data-value="@KeyOf(opt.Value)"
|
|
23
23
|
aria-selected="@(selected ? "true" : "false")">
|
|
24
|
-
<span class="fdy-combo__check"
|
|
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
|
@@ -1010,7 +1010,14 @@ a { color: var(--color-primary); }
|
|
|
1010
1010
|
.fdy-combo__option.is-highlighted{background:var(--color-surface-3);}
|
|
1011
1011
|
.fdy-combo__option[aria-selected="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
|
|
1012
1012
|
.fdy-combo__option.is-highlighted[aria-selected="true"]{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--color-primary) 55%,transparent);}
|
|
1013
|
+
/* The tick is CSS, not content (#004 §2). Inside role="option" it joined the option's ACCESSIBLE
|
|
1014
|
+
NAME, so the selected option announced "✓August" while every other announced "August": the state
|
|
1015
|
+
was read twice — once as aria-selected, once as decoration — and a test asking for an option by
|
|
1016
|
+
its own label stopped matching the one that was selected. The `/ ""` is alt text: the glyph is
|
|
1017
|
+
painted, the accessibility tree gets nothing. Same reasoning as .fdy-label--required (1.29.0).
|
|
1018
|
+
The empty box still reserves its width, so labels stay aligned whether selected or not. */
|
|
1013
1019
|
.fdy-combo__check{display:inline-flex;justify-content:center;width:var(--space-4);flex:none;font-weight:var(--weight-bold);color:var(--color-primary);}
|
|
1020
|
+
.fdy-combo__option[aria-selected="true"] .fdy-combo__check::after{content:"✓" / "";}
|
|
1014
1021
|
@media (prefers-reduced-motion:no-preference){.fdy-combo__listbox:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
|
|
1015
1022
|
@keyframes fdy-combo-in{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:none;}}
|
|
1016
1023
|
|
|
@@ -1114,7 +1121,20 @@ a { color: var(--color-primary); }
|
|
|
1114
1121
|
|
|
1115
1122
|
/* Calendar grid */
|
|
1116
1123
|
.fdy-cal__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);}
|
|
1117
|
-
|
|
1124
|
+
/* The title is a CONTROL now (#004 §1): it drills to the month grid, so it needs the UA button box
|
|
1125
|
+
reset and the same hover/focus treatment as the arrows beside it. */
|
|
1126
|
+
.fdy-cal__title{flex:1;appearance:none;border:0;background:transparent;padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);text-align:center;font-family:inherit;font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--color-text);text-transform:capitalize;cursor:pointer;}
|
|
1127
|
+
.fdy-cal__title:hover{background:var(--color-surface-2);}
|
|
1128
|
+
.fdy-cal__title:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|
|
1129
|
+
/* Month grid — 3×4 over the same track width as the 7-column day grid, so the panel does not resize
|
|
1130
|
+
when you drill and the pointer does not have to travel. */
|
|
1131
|
+
.fdy-cal__grid--months{grid-template-columns:repeat(3,1fr);gap:var(--space-1);}
|
|
1132
|
+
.fdy-cal__month{display:inline-flex;align-items:center;justify-content:center;height:2.25rem;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);font-family:inherit;font-size:var(--text-sm);text-transform:capitalize;cursor:pointer;}
|
|
1133
|
+
.fdy-cal__month:hover{background:var(--color-surface-2);}
|
|
1134
|
+
.fdy-cal__month:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|
|
1135
|
+
.fdy-cal__month:disabled{opacity:.4;cursor:not-allowed;background:transparent;}
|
|
1136
|
+
.fdy-cal__month.is-today{font-weight:var(--weight-bold);box-shadow:inset 0 0 0 1.5px var(--color-border-strong);}
|
|
1137
|
+
.fdy-cal__month.is-selected{background:var(--color-primary);color:var(--color-on-primary);font-weight:var(--weight-semibold);}
|
|
1118
1138
|
.fdy-cal__nav{display:inline-flex;align-items:center;justify-content:center;width:1.75rem;height:1.75rem;flex:none;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-muted);font-size:var(--text-lg);line-height:1;cursor:pointer;}
|
|
1119
1139
|
.fdy-cal__nav:hover{background:var(--color-surface-2);color:var(--color-text);}
|
|
1120
1140
|
.fdy-cal__nav:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|
package/dist/freeday.css
CHANGED
|
@@ -627,7 +627,14 @@ a { color: var(--color-primary); }
|
|
|
627
627
|
.fdy-combo__option.is-highlighted{background:var(--color-surface-3);}
|
|
628
628
|
.fdy-combo__option[aria-selected="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);font-weight:var(--weight-semibold);}
|
|
629
629
|
.fdy-combo__option.is-highlighted[aria-selected="true"]{box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--color-primary) 55%,transparent);}
|
|
630
|
+
/* The tick is CSS, not content (#004 §2). Inside role="option" it joined the option's ACCESSIBLE
|
|
631
|
+
NAME, so the selected option announced "✓August" while every other announced "August": the state
|
|
632
|
+
was read twice — once as aria-selected, once as decoration — and a test asking for an option by
|
|
633
|
+
its own label stopped matching the one that was selected. The `/ ""` is alt text: the glyph is
|
|
634
|
+
painted, the accessibility tree gets nothing. Same reasoning as .fdy-label--required (1.29.0).
|
|
635
|
+
The empty box still reserves its width, so labels stay aligned whether selected or not. */
|
|
630
636
|
.fdy-combo__check{display:inline-flex;justify-content:center;width:var(--space-4);flex:none;font-weight:var(--weight-bold);color:var(--color-primary);}
|
|
637
|
+
.fdy-combo__option[aria-selected="true"] .fdy-combo__check::after{content:"✓" / "";}
|
|
631
638
|
@media (prefers-reduced-motion:no-preference){.fdy-combo__listbox:not([hidden]){animation:fdy-combo-in var(--dur-fast) var(--ease-standard);}}
|
|
632
639
|
@keyframes fdy-combo-in{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:none;}}
|
|
633
640
|
|
|
@@ -731,7 +738,20 @@ a { color: var(--color-primary); }
|
|
|
731
738
|
|
|
732
739
|
/* Calendar grid */
|
|
733
740
|
.fdy-cal__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);}
|
|
734
|
-
|
|
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/docs/agent-onboarding.md
CHANGED
|
@@ -91,6 +91,7 @@ Blazor). Then verify the agent can actually read those files — an agent that c
|
|
|
91
91
|
| `docs/integrations.md` | How to bridge third-party libraries (validation, charts, dates, i18n…). |
|
|
92
92
|
| `docs/reference-screen.html` | A full screen assembled from the shell down. Open it in a browser. |
|
|
93
93
|
| `docs/agent-onboarding.md` | This file. |
|
|
94
|
+
| `CHANGELOG.md` | **What changed between the version this project had and the one it has now** — read it after every upgrade; each entry says what broke, what is new, and why. |
|
|
94
95
|
| `dist/` | Built CSS + enhancers. **`freeday.bundle.css` = tokens + components** (what `@cahyo-dimas/freeday/css` resolves to); `freeday.css` is components **only**, `freeday.tokens.css` tokens only — linking `freeday.css` alone leaves every `var(--…)` unresolved. Plus `freeday-*.js` and the `.d.ts` files. |
|
|
95
96
|
| `src/components/*.css` | The authoritative source for every class, when a doc is ambiguous. |
|
|
96
97
|
| `tokens/tokens.json` | Every token in W3C DTCG format — machine-readable. |
|
|
@@ -101,6 +102,28 @@ The live docs (with an interactive playground) are at
|
|
|
101
102
|
apps under `examples/` (Vue, React, Blazor) that are **not** in the npm tarball — is at
|
|
102
103
|
<https://github.com/cahyo-dimas/freeday-ui-kit>.
|
|
103
104
|
|
|
105
|
+
### After an upgrade, read the changelog first
|
|
106
|
+
|
|
107
|
+
`npm i @cahyo-dimas/freeday@latest` does not tell you what you gained. Read
|
|
108
|
+
`node_modules/@cahyo-dimas/freeday/CHANGELOG.md` down to the version this project was on before —
|
|
109
|
+
it is written for exactly this moment, and it is the difference between adopting a new affordance
|
|
110
|
+
and re-implementing it locally.
|
|
111
|
+
|
|
112
|
+
Recent additions most likely to replace something an app hand-rolled (all detailed in
|
|
113
|
+
`COMPONENTS.md`):
|
|
114
|
+
|
|
115
|
+
| Reach for | Instead of |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `.fdy-btn--stretch` | a hand-rolled overlay to make a card or list row clickable |
|
|
118
|
+
| `[data-fdy-number]` | a bare `<input type="number">` with the browser's own spin buttons |
|
|
119
|
+
| `clearable` on `FdyCfl` / `FdyDatepicker` | a value that can be set but never unset |
|
|
120
|
+
| `row.waiting(label)` on an upload row | a second status line saying the server is still working |
|
|
121
|
+
| `.fdy-label--required` | a `<span>` asterisk you have to remember to `aria-hidden` |
|
|
122
|
+
| `.fdy-text-warning` / `-danger` / `-success` | a caption class on a line that should stand out |
|
|
123
|
+
| `.fdy-icon` | your own `width:1em;height:1em` rule, once per project |
|
|
124
|
+
| `data-density="comfortable"` | restating the default spacing tokens on a wrapper |
|
|
125
|
+
| the calendar's month grid (press the title) | clicking "previous month" thirty times |
|
|
126
|
+
|
|
104
127
|
## 3. Starting a new screen
|
|
105
128
|
|
|
106
129
|
The order matters; skipping to components is what produces flat, identical-card screens.
|
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.1"` (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);}
|