@cahyo-dimas/freeday 3.7.0 → 3.9.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 +197 -0
- package/COMPONENTS.md +214 -18
- package/README.id.md +4 -4
- package/README.md +4 -3
- package/adapters/blazor/FdyDatepicker.razor +3 -1
- package/adapters/blazor/FdyDatepicker.razor.cs +36 -1
- package/adapters/blazor/FdyMonthpicker.razor +29 -0
- package/adapters/blazor/FdyMonthpicker.razor.cs +102 -0
- package/adapters/blazor/FdyTable.razor +6 -1
- package/adapters/blazor/FdyTable.razor.cs +33 -0
- package/adapters/blazor/FdyTableFooter.razor +6 -6
- package/adapters/blazor/FdyTableFooter.razor.cs +36 -0
- package/adapters/core/month-grid.d.ts +116 -0
- package/adapters/core/month-grid.js +234 -0
- package/adapters/react/components/FdyDatepicker.tsx +164 -166
- package/adapters/react/components/FdyMonthpicker.tsx +400 -0
- package/adapters/react/components/FdyTable.tsx +38 -2
- package/adapters/react/components/FdyTableFilter.tsx +13 -1
- package/adapters/react/components/FdyTableFooter.tsx +37 -6
- package/adapters/react/index.d.ts +6 -2
- package/adapters/react/index.js +1 -0
- package/adapters/react/usePopover.ts +54 -9
- package/adapters/vue/components/FdyDatepicker.vue +158 -160
- package/adapters/vue/components/FdyMonthpicker.vue +387 -0
- package/adapters/vue/components/FdyTable.vue +33 -1
- package/adapters/vue/components/FdyTableFilter.vue +13 -1
- package/adapters/vue/components/FdyTableFooter.vue +39 -6
- package/adapters/vue/index.d.ts +16 -0
- package/adapters/vue/index.js +1 -0
- package/adapters/vue/usePopover.ts +53 -10
- package/dist/freeday-datepicker.js +68 -14
- package/dist/freeday-menu.js +15 -1
- package/dist/freeday-popover.js +33 -11
- package/dist/freeday-table.js +64 -5
- package/dist/freeday-tabs.js +21 -2
- package/dist/freeday.bundle.css +50 -2
- package/dist/freeday.css +50 -2
- package/dist/freeday.js +201 -33
- package/docs/agent-onboarding.md +3 -3
- package/docs/getting-started.md +6 -6
- package/docs/integrations.md +22 -4
- package/package.json +2 -2
- package/src/components/table.css +19 -2
- package/src/components/tabs.css +31 -0
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import type { JSX } from 'react';
|
|
2
2
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { usePopover } from '../usePopover';
|
|
4
|
+
/* The month and year grids, which `FdyMonthpicker` reads from the same module. They were written
|
|
5
|
+
here and moved out when the month picker arrived: a second copy of the bounds rule is a second
|
|
6
|
+
place to fix it, and this release spent three items on exactly that failure in the panel-position
|
|
7
|
+
logic (#079). What the two components do differently with the grid stays in each of them, which
|
|
8
|
+
is one branch: whether choosing a month commits or walks down into its days. */
|
|
9
|
+
import {
|
|
10
|
+
YEARS_PER_PAGE,
|
|
11
|
+
addMonths,
|
|
12
|
+
monthCells as buildMonthCells,
|
|
13
|
+
monthDisabled,
|
|
14
|
+
monthFormatters,
|
|
15
|
+
monthKeyIntent,
|
|
16
|
+
pageLocale,
|
|
17
|
+
parseISOMonth,
|
|
18
|
+
startOfDay,
|
|
19
|
+
startOfMonth,
|
|
20
|
+
toISOMonth,
|
|
21
|
+
yearCells as buildYearCells,
|
|
22
|
+
yearKeyIntent,
|
|
23
|
+
yearPageStart,
|
|
24
|
+
yearRangeLabel,
|
|
25
|
+
type FdyMonthCell,
|
|
26
|
+
type FdyMonthFormatters,
|
|
27
|
+
type FdyYearCell,
|
|
28
|
+
} from '../../core/month-grid.js';
|
|
4
29
|
|
|
5
30
|
// React port of adapters/vue/components/FdyDatepicker.vue over freeday's `.fdy-datepicker` /
|
|
6
31
|
// `.fdy-cal__*` classes (see src/components/datepicker.css). Re-implements the single-date
|
|
@@ -9,6 +34,12 @@ import { usePopover } from '../usePopover';
|
|
|
9
34
|
// (outside-click + focusout) mirrors the already-approved FdyCombo.tsx pattern. Range mode is
|
|
10
35
|
// out of scope (v1 = single date).
|
|
11
36
|
|
|
37
|
+
/** Which grid is on screen. The drill goes days -> months -> years and back down. */
|
|
38
|
+
type CalendarLevel = 'days' | 'months' | 'years';
|
|
39
|
+
|
|
40
|
+
/** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
|
|
41
|
+
export type FdyDatepickerMode = 'day' | 'month';
|
|
42
|
+
|
|
12
43
|
interface DayCell {
|
|
13
44
|
date: Date;
|
|
14
45
|
iso: string;
|
|
@@ -21,27 +52,20 @@ interface DayCell {
|
|
|
21
52
|
focusable: boolean;
|
|
22
53
|
}
|
|
23
54
|
|
|
24
|
-
interface YearCell {
|
|
25
|
-
year: number;
|
|
26
|
-
selected: boolean;
|
|
27
|
-
today: boolean;
|
|
28
|
-
disabled: boolean;
|
|
29
|
-
focusable: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface MonthCell {
|
|
33
|
-
index: number;
|
|
34
|
-
label: string;
|
|
35
|
-
ariaLabel: string;
|
|
36
|
-
selected: boolean;
|
|
37
|
-
today: boolean;
|
|
38
|
-
disabled: boolean;
|
|
39
|
-
focusable: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
55
|
export interface FdyDatepickerProps {
|
|
56
|
+
/** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
|
|
43
57
|
value: string | null;
|
|
44
58
|
onChange: (value: string) => void;
|
|
59
|
+
/**
|
|
60
|
+
* `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and
|
|
61
|
+
* answers with `YYYY-MM`.
|
|
62
|
+
*
|
|
63
|
+
* @deprecated Use `FdyMonthpicker` for a month. This prop makes `value`, `min` and `max` carry
|
|
64
|
+
* two different formats while a *different* prop decides which, so the type cannot state what to
|
|
65
|
+
* store in them. `FdyMonthpicker` has one format per prop and no mode to read. Still works,
|
|
66
|
+
* unchanged, and removed in 4.0.
|
|
67
|
+
*/
|
|
68
|
+
mode?: FdyDatepickerMode;
|
|
45
69
|
min?: string;
|
|
46
70
|
max?: string;
|
|
47
71
|
locale?: string;
|
|
@@ -89,12 +113,6 @@ function parseISO(s: string | null | undefined): Date | null {
|
|
|
89
113
|
const d: Date = new Date(y, m - 1, day);
|
|
90
114
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
91
115
|
}
|
|
92
|
-
function startOfDay(d: Date): Date {
|
|
93
|
-
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
94
|
-
}
|
|
95
|
-
function startOfMonth(d: Date): Date {
|
|
96
|
-
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
97
|
-
}
|
|
98
116
|
function sameDay(a: Date | null, b: Date | null): boolean {
|
|
99
117
|
return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
100
118
|
}
|
|
@@ -103,11 +121,6 @@ function addDays(d: Date, n: number): Date {
|
|
|
103
121
|
x.setDate(x.getDate() + n);
|
|
104
122
|
return x;
|
|
105
123
|
}
|
|
106
|
-
function addMonths(d: Date, n: number): Date {
|
|
107
|
-
const x: Date = new Date(d);
|
|
108
|
-
x.setMonth(x.getMonth() + n);
|
|
109
|
-
return x;
|
|
110
|
-
}
|
|
111
124
|
|
|
112
125
|
function dayClassName(cell: DayCell): string {
|
|
113
126
|
let cls = 'fdy-cal__day';
|
|
@@ -131,18 +144,23 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
131
144
|
// don't steal DOM focus off the button, matching the Vue template's behavior.
|
|
132
145
|
const pendingFocusRef = useRef<string | null>(null);
|
|
133
146
|
|
|
147
|
+
const isMonthMode: boolean = props.mode === 'month';
|
|
148
|
+
/** Resolved once so every formatter below reads the same tag. */
|
|
149
|
+
const activeLocale: string = props.locale ?? pageLocale();
|
|
150
|
+
const parseValue = isMonthMode ? parseISOMonth : parseISO;
|
|
151
|
+
|
|
134
152
|
const [open, setOpen] = useState<boolean>(false);
|
|
135
|
-
/*
|
|
136
|
-
title. Before this the only pointer route to another month was one click per month.
|
|
137
|
-
|
|
153
|
+
/* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
|
|
154
|
+
the title. Before this the only pointer route to another month was one click per month.
|
|
155
|
+
Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with.
|
|
156
|
+
|
|
157
|
+
Month mode has no level below months, so the day grid is unreachable by construction rather
|
|
158
|
+
than by every path remembering not to ask for it. */
|
|
159
|
+
const startLevel = (): CalendarLevel => (props.mode === 'month' ? 'months' : 'days');
|
|
160
|
+
const [level, setLevel] = useState<CalendarLevel>(startLevel);
|
|
138
161
|
const [focusMonth, setFocusMonth] = useState<number>(0);
|
|
139
162
|
const [focusYear, setFocusYear] = useState<number>(0);
|
|
140
163
|
|
|
141
|
-
/* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring on the year in
|
|
142
|
-
view would make the arrows land on overlapping windows, and the same year would sit somewhere
|
|
143
|
-
different every step. */
|
|
144
|
-
const YEARS_PER_PAGE = 12;
|
|
145
|
-
const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
|
|
146
164
|
const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
147
165
|
const pendingMonthFocusRef = useRef<number | null>(null);
|
|
148
166
|
const yearRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
@@ -157,13 +175,13 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
157
175
|
panelRef.current?.setAttribute('popover', 'manual');
|
|
158
176
|
}, []);
|
|
159
177
|
|
|
160
|
-
const selectedDate: Date | null = useMemo((): Date | null =>
|
|
161
|
-
const minDate: Date | null = useMemo((): Date | null =>
|
|
162
|
-
const maxDate: Date | null = useMemo((): Date | null =>
|
|
178
|
+
const selectedDate: Date | null = useMemo((): Date | null => parseValue(props.value), [props.value, isMonthMode]);
|
|
179
|
+
const minDate: Date | null = useMemo((): Date | null => parseValue(props.min), [props.min, isMonthMode]);
|
|
180
|
+
const maxDate: Date | null = useMemo((): Date | null => parseValue(props.max), [props.max, isMonthMode]);
|
|
163
181
|
const isDisabled: boolean = props.disabled === true;
|
|
164
182
|
const isReadonly: boolean = props.readonly === true;
|
|
165
183
|
const isInvalid: boolean = props.invalid === true;
|
|
166
|
-
const displayPlaceholder: string = props.placeholder ?? 'Select date';
|
|
184
|
+
const displayPlaceholder: string = props.placeholder ?? (isMonthMode ? 'Select month' : 'Select date');
|
|
167
185
|
const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
|
|
168
186
|
const chooseMonthLabelText: string = props.chooseMonthLabel ?? 'Choose month';
|
|
169
187
|
const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
|
|
@@ -171,20 +189,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
171
189
|
const chooseYearLabelText: string = props.chooseYearLabel ?? 'Choose year';
|
|
172
190
|
const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
|
|
173
191
|
const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
|
|
174
|
-
const
|
|
175
|
-
():
|
|
176
|
-
[
|
|
177
|
-
);
|
|
178
|
-
const monthNameFmt: Intl.DateTimeFormat = useMemo(
|
|
179
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
|
|
180
|
-
[props.locale],
|
|
192
|
+
const formatters: FdyMonthFormatters = useMemo(
|
|
193
|
+
(): FdyMonthFormatters => monthFormatters(activeLocale),
|
|
194
|
+
[activeLocale],
|
|
181
195
|
);
|
|
182
196
|
const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
|
|
183
197
|
const nextMonthLabelText: string = props.nextMonthLabel ?? 'Next month';
|
|
184
198
|
const clearLabelText: string = props.clearLabel ?? 'Clear date';
|
|
185
199
|
|
|
186
|
-
const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(
|
|
187
|
-
const [focusDate, setFocusDate] = useState<Date>((): Date =>
|
|
200
|
+
const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseValue(props.value) ?? new Date()));
|
|
201
|
+
const [focusDate, setFocusDate] = useState<Date>((): Date => parseValue(props.value) ?? new Date());
|
|
188
202
|
|
|
189
203
|
// Keep the roving-tabindex cell inside the visible month so the grid stays keyboard-reachable
|
|
190
204
|
// after navigating with Home/End/PageUp/PageDown (mirrors freeday-datepicker.js's clamp).
|
|
@@ -195,23 +209,28 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
195
209
|
}, [focusDate, viewMonth]);
|
|
196
210
|
|
|
197
211
|
const monthFmt: Intl.DateTimeFormat = useMemo(
|
|
198
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
199
|
-
[
|
|
212
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'long', year: 'numeric' }),
|
|
213
|
+
[activeLocale],
|
|
200
214
|
);
|
|
201
215
|
const valueFmt: Intl.DateTimeFormat = useMemo(
|
|
202
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
203
|
-
[
|
|
216
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { day: '2-digit', month: 'short', year: 'numeric' }),
|
|
217
|
+
[activeLocale],
|
|
204
218
|
);
|
|
205
219
|
const dayLabelFmt: Intl.DateTimeFormat = useMemo(
|
|
206
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
207
|
-
[
|
|
220
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
|
|
221
|
+
[activeLocale],
|
|
208
222
|
);
|
|
209
223
|
const dowFmt: Intl.DateTimeFormat = useMemo(
|
|
210
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
211
|
-
[
|
|
224
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { weekday: 'short' }),
|
|
225
|
+
[activeLocale],
|
|
212
226
|
);
|
|
213
227
|
|
|
214
|
-
|
|
228
|
+
/* Month mode shows the month through the SAME formatter the month cells and the day grid's title
|
|
229
|
+
already use, which is the whole point of the mode: <input type="month"> names the month in the
|
|
230
|
+
browser's language, this names it in the page's. */
|
|
231
|
+
const displayValue: string = selectedDate === null
|
|
232
|
+
? displayPlaceholder
|
|
233
|
+
: (isMonthMode ? formatters.name : valueFmt).format(selectedDate);
|
|
215
234
|
|
|
216
235
|
const weekdayHeaders: string[] = useMemo((): string[] => {
|
|
217
236
|
const monday: Date = new Date(2024, 0, 1); // a known Monday
|
|
@@ -224,36 +243,22 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
224
243
|
return false;
|
|
225
244
|
}
|
|
226
245
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const monthCells: MonthCell[] = useMemo((): MonthCell[] => {
|
|
238
|
-
const year: number = viewMonth.getFullYear();
|
|
239
|
-
const today: Date = new Date();
|
|
240
|
-
const sel: Date | null = parseISO(props.value);
|
|
241
|
-
return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
|
|
242
|
-
const cellDate: Date = new Date(year, index, 1);
|
|
243
|
-
return {
|
|
244
|
-
index,
|
|
245
|
-
label: monthCellFmt.format(cellDate),
|
|
246
|
-
ariaLabel: monthNameFmt.format(cellDate),
|
|
247
|
-
selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
|
|
248
|
-
today: today.getFullYear() === year && today.getMonth() === index,
|
|
249
|
-
disabled: isMonthDisabled(year, index),
|
|
250
|
-
focusable: index === focusMonth,
|
|
251
|
-
};
|
|
252
|
-
});
|
|
253
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
-
}, [viewMonth, props.value, focusMonth, monthCellFmt, monthNameFmt, minDate, maxDate]);
|
|
246
|
+
const monthCells: FdyMonthCell[] = useMemo((): FdyMonthCell[] => buildMonthCells({
|
|
247
|
+
year: viewMonth.getFullYear(),
|
|
248
|
+
selected: parseValue(props.value),
|
|
249
|
+
min: minDate,
|
|
250
|
+
max: maxDate,
|
|
251
|
+
focusMonth,
|
|
252
|
+
formatters,
|
|
253
|
+
}),
|
|
254
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
255
|
+
[viewMonth, props.value, focusMonth, formatters, minDate, maxDate]);
|
|
255
256
|
|
|
256
257
|
const gridCells: DayCell[] = useMemo((): DayCell[] => {
|
|
258
|
+
/* Month mode never mounts this grid, and useMemo runs whether or not its result is read, so
|
|
259
|
+
without this every arrow key in the month grid would format forty two day labels through Intl
|
|
260
|
+
for cells nobody sees. Vue needs no such guard: a computed is lazy. */
|
|
261
|
+
if (isMonthMode) return [];
|
|
257
262
|
const first: Date = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
|
|
258
263
|
const offset: number = (first.getDay() + 6) % 7; // Monday-first
|
|
259
264
|
const startCell: Date = addDays(first, -offset);
|
|
@@ -278,7 +283,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
278
283
|
// Deps intentionally mirror the port recipe, not every closure read: minDate/maxDate,
|
|
279
284
|
// dayLabelFmt and effectiveFocusDate all derive deterministically from the listed deps
|
|
280
285
|
// within the same render pass, so this stays in sync without listing them separately.
|
|
281
|
-
}, [viewMonth, props.value, props.min, props.max,
|
|
286
|
+
}, [viewMonth, props.value, props.min, props.max, activeLocale, isMonthMode]);
|
|
282
287
|
|
|
283
288
|
// Focus the roving-tabindex cell after a render triggered by moveFocus/openPanel (React's
|
|
284
289
|
// analogue of Vue's `nextTick(focusCell)`). Runs after every render; cheap no-op otherwise.
|
|
@@ -305,10 +310,27 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
305
310
|
|
|
306
311
|
function openMonthGrid(): void {
|
|
307
312
|
setFocusMonth(viewMonth.getMonth());
|
|
308
|
-
|
|
313
|
+
setLevel('months');
|
|
309
314
|
pendingMonthFocusRef.current = viewMonth.getMonth();
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
/* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
|
|
318
|
+
day mode a month is a step on the way down; in month mode it is the answer. */
|
|
319
|
+
function chooseMonth(index: number): void {
|
|
320
|
+
if (isMonthMode) pickMonth(index);
|
|
321
|
+
else openMonth(index);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pickMonth(index: number): void {
|
|
325
|
+
const year: number = viewMonth.getFullYear();
|
|
326
|
+
if (monthDisabled(year, index, minDate, maxDate)) return;
|
|
327
|
+
const first: Date = new Date(year, index, 1);
|
|
328
|
+
setFocusMonth(index);
|
|
329
|
+
setFocusDate(first);
|
|
330
|
+
props.onChange(toISOMonth(first));
|
|
331
|
+
closePanel(true);
|
|
332
|
+
}
|
|
333
|
+
|
|
312
334
|
/* Picking a month is NAVIGATION, not selection: back to the day grid with the roving cell clamped
|
|
313
335
|
into the new month, so nothing is committed until a day is chosen. */
|
|
314
336
|
function openMonth(index: number): void {
|
|
@@ -317,47 +339,33 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
317
339
|
const next: Date = new Date(year, index, Math.min(focusDate.getDate(), daysInMonth));
|
|
318
340
|
setViewMonth(new Date(year, index, 1));
|
|
319
341
|
setFocusDate(next);
|
|
320
|
-
|
|
342
|
+
setLevel('days');
|
|
321
343
|
pendingFocusRef.current = toISO(next);
|
|
322
344
|
}
|
|
323
345
|
|
|
346
|
+
/** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
|
|
324
347
|
function moveMonthFocus(index: number, yearDelta: number): void {
|
|
325
348
|
if (yearDelta !== 0) setViewMonth(addMonths(viewMonth, yearDelta * 12));
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
pendingMonthFocusRef.current = next;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** A year is unreachable only when EVERY month in it falls outside min/max. */
|
|
332
|
-
function isYearDisabled(year: number): boolean {
|
|
333
|
-
if (minDate !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate).getTime()) return true;
|
|
334
|
-
if (maxDate !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate).getTime()) return true;
|
|
335
|
-
return false;
|
|
349
|
+
setFocusMonth(index);
|
|
350
|
+
pendingMonthFocusRef.current = index;
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
const yearPage: number = yearPageStart(focusYear);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
const yearCells:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
today: thisYear === year,
|
|
351
|
-
disabled: isYearDisabled(year),
|
|
352
|
-
focusable: year === focusYear,
|
|
353
|
-
};
|
|
354
|
-
});
|
|
355
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
356
|
-
}, [focusYear, props.value, minDate, maxDate]);
|
|
354
|
+
const yearRangeText: string = yearRangeLabel(yearPage);
|
|
355
|
+
|
|
356
|
+
const yearCells: FdyYearCell[] = useMemo((): FdyYearCell[] => buildYearCells({
|
|
357
|
+
pageStart: yearPageStart(focusYear),
|
|
358
|
+
selected: parseValue(props.value),
|
|
359
|
+
min: minDate,
|
|
360
|
+
max: maxDate,
|
|
361
|
+
focusYear,
|
|
362
|
+
}),
|
|
363
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
364
|
+
[focusYear, props.value, minDate, maxDate]);
|
|
357
365
|
|
|
358
366
|
function openYearGrid(): void {
|
|
359
367
|
setFocusYear(viewMonth.getFullYear());
|
|
360
|
-
|
|
368
|
+
setLevel('years');
|
|
361
369
|
pendingYearFocusRef.current = viewMonth.getFullYear();
|
|
362
370
|
}
|
|
363
371
|
|
|
@@ -366,7 +374,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
366
374
|
function openYear(year: number): void {
|
|
367
375
|
setViewMonth(new Date(year, viewMonth.getMonth(), 1));
|
|
368
376
|
setFocusMonth(viewMonth.getMonth());
|
|
369
|
-
|
|
377
|
+
setLevel('months');
|
|
370
378
|
pendingMonthFocusRef.current = viewMonth.getMonth();
|
|
371
379
|
}
|
|
372
380
|
|
|
@@ -377,41 +385,22 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
377
385
|
}
|
|
378
386
|
|
|
379
387
|
function onYearKeydown(e: React.KeyboardEvent): void {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
|
|
388
|
-
case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
|
|
389
|
-
case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
|
|
390
|
-
case 'Enter':
|
|
391
|
-
case ' ': openYear(focusYear); break;
|
|
392
|
-
case 'Escape': closePanel(true); break;
|
|
393
|
-
default: handled = false;
|
|
394
|
-
}
|
|
395
|
-
if (handled) { e.preventDefault(); e.stopPropagation(); }
|
|
388
|
+
const intent = yearKeyIntent(e.key, focusYear);
|
|
389
|
+
if (intent.action === 'none') return;
|
|
390
|
+
e.preventDefault();
|
|
391
|
+
e.stopPropagation();
|
|
392
|
+
if (intent.action === 'focus') moveYearFocus(intent.year);
|
|
393
|
+
else if (intent.action === 'choose') openYear(intent.year);
|
|
394
|
+
else closePanel(true);
|
|
396
395
|
}
|
|
397
396
|
|
|
398
397
|
function onMonthKeydown(e: React.KeyboardEvent): void {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
case 'Home': moveMonthFocus(0, 0); break;
|
|
406
|
-
case 'End': moveMonthFocus(11, 0); break;
|
|
407
|
-
case 'PageUp': moveMonthFocus(focusMonth, -1); break;
|
|
408
|
-
case 'PageDown': moveMonthFocus(focusMonth, 1); break;
|
|
409
|
-
case 'Enter':
|
|
410
|
-
case ' ': openMonth(focusMonth); break;
|
|
411
|
-
case 'Escape': closePanel(true); break;
|
|
412
|
-
default: handled = false;
|
|
413
|
-
}
|
|
414
|
-
if (handled) e.preventDefault();
|
|
398
|
+
const intent = monthKeyIntent(e.key, focusMonth);
|
|
399
|
+
if (intent.action === 'none') return;
|
|
400
|
+
e.preventDefault();
|
|
401
|
+
if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
|
|
402
|
+
else if (intent.action === 'choose') chooseMonth(intent.index);
|
|
403
|
+
else closePanel(true);
|
|
415
404
|
}
|
|
416
405
|
|
|
417
406
|
function moveFocus(next: Date): void {
|
|
@@ -441,7 +430,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
441
430
|
const next: Date = selectedDate ?? focusDate ?? new Date();
|
|
442
431
|
setFocusDate(next);
|
|
443
432
|
setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
|
|
433
|
+
/* Reopening starts at the mode's own floor. Without this a month picker that drilled to the
|
|
434
|
+
year grid and was dismissed would open on years the next time, and a keyboard user would
|
|
435
|
+
land on a grid that cannot answer. */
|
|
436
|
+
setLevel(startLevel());
|
|
444
437
|
setOpen(true);
|
|
438
|
+
if (isMonthMode) {
|
|
439
|
+
setFocusMonth(next.getMonth());
|
|
440
|
+
pendingMonthFocusRef.current = next.getMonth();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
445
443
|
pendingFocusRef.current = toISO(next);
|
|
446
444
|
}
|
|
447
445
|
|
|
@@ -575,10 +573,10 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
575
573
|
<button
|
|
576
574
|
type="button"
|
|
577
575
|
className="fdy-cal__nav"
|
|
578
|
-
aria-label={
|
|
576
|
+
aria-label={level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText}
|
|
579
577
|
onClick={(): void => {
|
|
580
|
-
if (
|
|
581
|
-
else if (
|
|
578
|
+
if (level === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
|
|
579
|
+
else if (level === 'months') moveMonthFocus(focusMonth, -1);
|
|
582
580
|
else setViewMonth(addMonths(viewMonth, -1));
|
|
583
581
|
}}
|
|
584
582
|
>
|
|
@@ -588,27 +586,27 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
588
586
|
id={titleId}
|
|
589
587
|
type="button"
|
|
590
588
|
className="fdy-cal__title"
|
|
591
|
-
aria-label={
|
|
592
|
-
onClick={(): void => { if (
|
|
589
|
+
aria-label={level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText}
|
|
590
|
+
onClick={(): void => { if (level === 'months') openYearGrid(); else openMonthGrid(); }}
|
|
593
591
|
>
|
|
594
|
-
{
|
|
592
|
+
{level === 'years' ? yearRangeText : level === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
|
|
595
593
|
</button>
|
|
596
594
|
<button
|
|
597
595
|
type="button"
|
|
598
596
|
className="fdy-cal__nav"
|
|
599
|
-
aria-label={
|
|
597
|
+
aria-label={level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText}
|
|
600
598
|
onClick={(): void => {
|
|
601
|
-
if (
|
|
602
|
-
else if (
|
|
599
|
+
if (level === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
|
|
600
|
+
else if (level === 'months') moveMonthFocus(focusMonth, 1);
|
|
603
601
|
else setViewMonth(addMonths(viewMonth, 1));
|
|
604
602
|
}}
|
|
605
603
|
>
|
|
606
604
|
›
|
|
607
605
|
</button>
|
|
608
606
|
</div>
|
|
609
|
-
{
|
|
607
|
+
{level === 'years' ? (
|
|
610
608
|
<div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
|
|
611
|
-
{yearCells.map((cell:
|
|
609
|
+
{yearCells.map((cell: FdyYearCell): JSX.Element => (
|
|
612
610
|
<button
|
|
613
611
|
key={cell.year}
|
|
614
612
|
ref={(el: HTMLButtonElement | null): void => { yearRefs.current[cell.year] = el; }}
|
|
@@ -625,9 +623,9 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
625
623
|
</button>
|
|
626
624
|
))}
|
|
627
625
|
</div>
|
|
628
|
-
) :
|
|
626
|
+
) : level === 'months' ? (
|
|
629
627
|
<div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
|
|
630
|
-
{monthCells.map((cell:
|
|
628
|
+
{monthCells.map((cell: FdyMonthCell): JSX.Element => (
|
|
631
629
|
<button
|
|
632
630
|
key={cell.index}
|
|
633
631
|
ref={(el: HTMLButtonElement | null): void => { monthRefs.current[cell.index] = el; }}
|
|
@@ -638,7 +636,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
638
636
|
aria-selected={cell.selected ? true : undefined}
|
|
639
637
|
disabled={cell.disabled}
|
|
640
638
|
tabIndex={cell.focusable ? 0 : -1}
|
|
641
|
-
onClick={(): void =>
|
|
639
|
+
onClick={(): void => chooseMonth(cell.index)}
|
|
642
640
|
>
|
|
643
641
|
{cell.label}
|
|
644
642
|
</button>
|