@cahyo-dimas/freeday 3.8.0 → 3.9.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 +126 -0
- package/COMPONENTS.md +112 -29
- package/README.id.md +4 -4
- package/README.md +4 -3
- package/adapters/blazor/FdyDatepicker.razor.cs +15 -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/core/month-grid.d.ts +116 -0
- package/adapters/core/month-grid.js +234 -0
- package/adapters/react/components/FdyDatepicker.tsx +78 -160
- 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/index.d.ts +5 -1
- package/adapters/react/index.js +1 -0
- package/adapters/react/usePopover.ts +54 -9
- package/adapters/vue/components/FdyDatepicker.vue +72 -159
- 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/index.d.ts +16 -0
- package/adapters/vue/index.js +1 -0
- package/adapters/vue/usePopover.ts +53 -10
- package/dist/freeday-table.js +64 -5
- package/dist/freeday.bundle.css +31 -6
- package/dist/freeday.css +31 -6
- package/dist/freeday.js +64 -5
- 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/app-shell.css +12 -4
- package/src/components/table.css +19 -2
|
@@ -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
|
|
@@ -27,29 +52,19 @@ interface DayCell {
|
|
|
27
52
|
focusable: boolean;
|
|
28
53
|
}
|
|
29
54
|
|
|
30
|
-
interface YearCell {
|
|
31
|
-
year: number;
|
|
32
|
-
selected: boolean;
|
|
33
|
-
today: boolean;
|
|
34
|
-
disabled: boolean;
|
|
35
|
-
focusable: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface MonthCell {
|
|
39
|
-
index: number;
|
|
40
|
-
label: string;
|
|
41
|
-
ariaLabel: string;
|
|
42
|
-
selected: boolean;
|
|
43
|
-
today: boolean;
|
|
44
|
-
disabled: boolean;
|
|
45
|
-
focusable: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
55
|
export interface FdyDatepickerProps {
|
|
49
56
|
/** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
|
|
50
57
|
value: string | null;
|
|
51
58
|
onChange: (value: string) => void;
|
|
52
|
-
/**
|
|
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
|
+
*/
|
|
53
68
|
mode?: FdyDatepickerMode;
|
|
54
69
|
min?: string;
|
|
55
70
|
max?: string;
|
|
@@ -88,32 +103,6 @@ function pad(n: number): string {
|
|
|
88
103
|
function toISO(d: Date): string {
|
|
89
104
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
90
105
|
}
|
|
91
|
-
function toISOMonth(d: Date): string {
|
|
92
|
-
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
93
|
-
}
|
|
94
|
-
/** Month mode reads BOTH shapes, and the dropped day is the point: a bound of `2026-03` lands on
|
|
95
|
-
* March 1st, which `isMonthDisabled` then compares a whole month against correctly at either end,
|
|
96
|
-
* so an app can pass the strings it already stores. A full date still works. */
|
|
97
|
-
function parseISOMonth(s: string | null | undefined): Date | null {
|
|
98
|
-
if (s === null || s === undefined || s === '') return null;
|
|
99
|
-
const parts: string[] = s.split('-');
|
|
100
|
-
if (parts.length < 2) return null;
|
|
101
|
-
const y: number = Number(parts[0]);
|
|
102
|
-
const m: number = Number(parts[1]);
|
|
103
|
-
const day: number = parts.length > 2 ? Number(parts[2]) : 1;
|
|
104
|
-
const d: Date = new Date(y, m - 1, day);
|
|
105
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
106
|
-
}
|
|
107
|
-
/* The page's own `lang`, which is what COMPONENTS.md has promised all along and what the vanilla
|
|
108
|
-
enhancer has always done. Handing `undefined` to Intl instead falls back to the BROWSER's
|
|
109
|
-
language, so an Indonesian reader saw Indonesian month names inside a kit that is English on
|
|
110
|
-
every other path, and an Indonesian PAGE read by an English browser got English ones. That is
|
|
111
|
-
the symptom this month mode was asked for, so the fallback has to be the page. */
|
|
112
|
-
function pageLocale(): string {
|
|
113
|
-
if (typeof document === 'undefined') return 'en';
|
|
114
|
-
// `||`, not `??`: an empty `lang=""` is not null and Intl throws RangeError on it.
|
|
115
|
-
return document.documentElement.getAttribute('lang') || 'en';
|
|
116
|
-
}
|
|
117
106
|
function parseISO(s: string | null | undefined): Date | null {
|
|
118
107
|
if (s === null || s === undefined || s === '') return null;
|
|
119
108
|
const parts: string[] = s.split('-');
|
|
@@ -124,12 +113,6 @@ function parseISO(s: string | null | undefined): Date | null {
|
|
|
124
113
|
const d: Date = new Date(y, m - 1, day);
|
|
125
114
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
126
115
|
}
|
|
127
|
-
function startOfDay(d: Date): Date {
|
|
128
|
-
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
129
|
-
}
|
|
130
|
-
function startOfMonth(d: Date): Date {
|
|
131
|
-
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
132
|
-
}
|
|
133
116
|
function sameDay(a: Date | null, b: Date | null): boolean {
|
|
134
117
|
return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
135
118
|
}
|
|
@@ -138,11 +121,6 @@ function addDays(d: Date, n: number): Date {
|
|
|
138
121
|
x.setDate(x.getDate() + n);
|
|
139
122
|
return x;
|
|
140
123
|
}
|
|
141
|
-
function addMonths(d: Date, n: number): Date {
|
|
142
|
-
const x: Date = new Date(d);
|
|
143
|
-
x.setMonth(x.getMonth() + n);
|
|
144
|
-
return x;
|
|
145
|
-
}
|
|
146
124
|
|
|
147
125
|
function dayClassName(cell: DayCell): string {
|
|
148
126
|
let cls = 'fdy-cal__day';
|
|
@@ -183,11 +161,6 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
183
161
|
const [focusMonth, setFocusMonth] = useState<number>(0);
|
|
184
162
|
const [focusYear, setFocusYear] = useState<number>(0);
|
|
185
163
|
|
|
186
|
-
/* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring on the year in
|
|
187
|
-
view would make the arrows land on overlapping windows, and the same year would sit somewhere
|
|
188
|
-
different every step. */
|
|
189
|
-
const YEARS_PER_PAGE = 12;
|
|
190
|
-
const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
|
|
191
164
|
const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
192
165
|
const pendingMonthFocusRef = useRef<number | null>(null);
|
|
193
166
|
const yearRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
@@ -216,12 +189,8 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
216
189
|
const chooseYearLabelText: string = props.chooseYearLabel ?? 'Choose year';
|
|
217
190
|
const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
|
|
218
191
|
const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
|
|
219
|
-
const
|
|
220
|
-
():
|
|
221
|
-
[activeLocale],
|
|
222
|
-
);
|
|
223
|
-
const monthNameFmt: Intl.DateTimeFormat = useMemo(
|
|
224
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'long', year: 'numeric' }),
|
|
192
|
+
const formatters: FdyMonthFormatters = useMemo(
|
|
193
|
+
(): FdyMonthFormatters => monthFormatters(activeLocale),
|
|
225
194
|
[activeLocale],
|
|
226
195
|
);
|
|
227
196
|
const prevMonthLabelText: string = props.prevMonthLabel ?? 'Previous month';
|
|
@@ -261,7 +230,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
261
230
|
browser's language, this names it in the page's. */
|
|
262
231
|
const displayValue: string = selectedDate === null
|
|
263
232
|
? displayPlaceholder
|
|
264
|
-
: (isMonthMode ?
|
|
233
|
+
: (isMonthMode ? formatters.name : valueFmt).format(selectedDate);
|
|
265
234
|
|
|
266
235
|
const weekdayHeaders: string[] = useMemo((): string[] => {
|
|
267
236
|
const monday: Date = new Date(2024, 0, 1); // a known Monday
|
|
@@ -274,34 +243,16 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
274
243
|
return false;
|
|
275
244
|
}
|
|
276
245
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const monthCells: MonthCell[] = useMemo((): MonthCell[] => {
|
|
288
|
-
const year: number = viewMonth.getFullYear();
|
|
289
|
-
const today: Date = new Date();
|
|
290
|
-
const sel: Date | null = parseValue(props.value);
|
|
291
|
-
return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
|
|
292
|
-
const cellDate: Date = new Date(year, index, 1);
|
|
293
|
-
return {
|
|
294
|
-
index,
|
|
295
|
-
label: monthCellFmt.format(cellDate),
|
|
296
|
-
ariaLabel: monthNameFmt.format(cellDate),
|
|
297
|
-
selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
|
|
298
|
-
today: today.getFullYear() === year && today.getMonth() === index,
|
|
299
|
-
disabled: isMonthDisabled(year, index),
|
|
300
|
-
focusable: index === focusMonth,
|
|
301
|
-
};
|
|
302
|
-
});
|
|
303
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
-
}, [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]);
|
|
305
256
|
|
|
306
257
|
const gridCells: DayCell[] = useMemo((): DayCell[] => {
|
|
307
258
|
/* Month mode never mounts this grid, and useMemo runs whether or not its result is read, so
|
|
@@ -372,7 +323,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
372
323
|
|
|
373
324
|
function pickMonth(index: number): void {
|
|
374
325
|
const year: number = viewMonth.getFullYear();
|
|
375
|
-
if (
|
|
326
|
+
if (monthDisabled(year, index, minDate, maxDate)) return;
|
|
376
327
|
const first: Date = new Date(year, index, 1);
|
|
377
328
|
setFocusMonth(index);
|
|
378
329
|
setFocusDate(first);
|
|
@@ -392,39 +343,25 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
392
343
|
pendingFocusRef.current = toISO(next);
|
|
393
344
|
}
|
|
394
345
|
|
|
346
|
+
/** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
|
|
395
347
|
function moveMonthFocus(index: number, yearDelta: number): void {
|
|
396
348
|
if (yearDelta !== 0) setViewMonth(addMonths(viewMonth, yearDelta * 12));
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
pendingMonthFocusRef.current = next;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** A year is unreachable only when EVERY month in it falls outside min/max. */
|
|
403
|
-
function isYearDisabled(year: number): boolean {
|
|
404
|
-
if (minDate !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate).getTime()) return true;
|
|
405
|
-
if (maxDate !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate).getTime()) return true;
|
|
406
|
-
return false;
|
|
349
|
+
setFocusMonth(index);
|
|
350
|
+
pendingMonthFocusRef.current = index;
|
|
407
351
|
}
|
|
408
352
|
|
|
409
353
|
const yearPage: number = yearPageStart(focusYear);
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
const yearCells:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
today: thisYear === year,
|
|
422
|
-
disabled: isYearDisabled(year),
|
|
423
|
-
focusable: year === focusYear,
|
|
424
|
-
};
|
|
425
|
-
});
|
|
426
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
427
|
-
}, [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]);
|
|
428
365
|
|
|
429
366
|
function openYearGrid(): void {
|
|
430
367
|
setFocusYear(viewMonth.getFullYear());
|
|
@@ -448,41 +385,22 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
448
385
|
}
|
|
449
386
|
|
|
450
387
|
function onYearKeydown(e: React.KeyboardEvent): void {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
case 'End': moveYearFocus(yearPageStart(focusYear) + YEARS_PER_PAGE - 1); break;
|
|
459
|
-
case 'PageUp': moveYearFocus(focusYear - YEARS_PER_PAGE); break;
|
|
460
|
-
case 'PageDown': moveYearFocus(focusYear + YEARS_PER_PAGE); break;
|
|
461
|
-
case 'Enter':
|
|
462
|
-
case ' ': openYear(focusYear); break;
|
|
463
|
-
case 'Escape': closePanel(true); break;
|
|
464
|
-
default: handled = false;
|
|
465
|
-
}
|
|
466
|
-
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);
|
|
467
395
|
}
|
|
468
396
|
|
|
469
397
|
function onMonthKeydown(e: React.KeyboardEvent): void {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
case 'Home': moveMonthFocus(0, 0); break;
|
|
477
|
-
case 'End': moveMonthFocus(11, 0); break;
|
|
478
|
-
case 'PageUp': moveMonthFocus(focusMonth, -1); break;
|
|
479
|
-
case 'PageDown': moveMonthFocus(focusMonth, 1); break;
|
|
480
|
-
case 'Enter':
|
|
481
|
-
case ' ': chooseMonth(focusMonth); break;
|
|
482
|
-
case 'Escape': closePanel(true); break;
|
|
483
|
-
default: handled = false;
|
|
484
|
-
}
|
|
485
|
-
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);
|
|
486
404
|
}
|
|
487
405
|
|
|
488
406
|
function moveFocus(next: Date): void {
|
|
@@ -671,7 +589,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
671
589
|
aria-label={level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText}
|
|
672
590
|
onClick={(): void => { if (level === 'months') openYearGrid(); else openMonthGrid(); }}
|
|
673
591
|
>
|
|
674
|
-
{level === 'years' ?
|
|
592
|
+
{level === 'years' ? yearRangeText : level === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
|
|
675
593
|
</button>
|
|
676
594
|
<button
|
|
677
595
|
type="button"
|
|
@@ -688,7 +606,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
688
606
|
</div>
|
|
689
607
|
{level === 'years' ? (
|
|
690
608
|
<div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
|
|
691
|
-
{yearCells.map((cell:
|
|
609
|
+
{yearCells.map((cell: FdyYearCell): JSX.Element => (
|
|
692
610
|
<button
|
|
693
611
|
key={cell.year}
|
|
694
612
|
ref={(el: HTMLButtonElement | null): void => { yearRefs.current[cell.year] = el; }}
|
|
@@ -707,7 +625,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
707
625
|
</div>
|
|
708
626
|
) : level === 'months' ? (
|
|
709
627
|
<div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
|
|
710
|
-
{monthCells.map((cell:
|
|
628
|
+
{monthCells.map((cell: FdyMonthCell): JSX.Element => (
|
|
711
629
|
<button
|
|
712
630
|
key={cell.index}
|
|
713
631
|
ref={(el: HTMLButtonElement | null): void => { monthRefs.current[cell.index] = el; }}
|