@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/COMPONENTS.md +112 -29
  3. package/README.id.md +4 -4
  4. package/README.md +4 -3
  5. package/adapters/blazor/FdyDatepicker.razor.cs +15 -1
  6. package/adapters/blazor/FdyMonthpicker.razor +29 -0
  7. package/adapters/blazor/FdyMonthpicker.razor.cs +102 -0
  8. package/adapters/blazor/FdyTable.razor +6 -1
  9. package/adapters/blazor/FdyTable.razor.cs +33 -0
  10. package/adapters/core/month-grid.d.ts +116 -0
  11. package/adapters/core/month-grid.js +234 -0
  12. package/adapters/react/components/FdyDatepicker.tsx +78 -160
  13. package/adapters/react/components/FdyMonthpicker.tsx +400 -0
  14. package/adapters/react/components/FdyTable.tsx +38 -2
  15. package/adapters/react/components/FdyTableFilter.tsx +13 -1
  16. package/adapters/react/index.d.ts +5 -1
  17. package/adapters/react/index.js +1 -0
  18. package/adapters/react/usePopover.ts +54 -9
  19. package/adapters/vue/components/FdyDatepicker.vue +72 -159
  20. package/adapters/vue/components/FdyMonthpicker.vue +387 -0
  21. package/adapters/vue/components/FdyTable.vue +33 -1
  22. package/adapters/vue/components/FdyTableFilter.vue +13 -1
  23. package/adapters/vue/index.d.ts +16 -0
  24. package/adapters/vue/index.js +1 -0
  25. package/adapters/vue/usePopover.ts +53 -10
  26. package/dist/freeday-table.js +64 -5
  27. package/dist/freeday.bundle.css +31 -6
  28. package/dist/freeday.css +31 -6
  29. package/dist/freeday.js +64 -5
  30. package/docs/agent-onboarding.md +3 -3
  31. package/docs/getting-started.md +6 -6
  32. package/docs/integrations.md +22 -4
  33. package/package.json +2 -2
  34. package/src/components/app-shell.css +12 -4
  35. 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
- /** `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and answers with `YYYY-MM`. */
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 monthCellFmt: Intl.DateTimeFormat = useMemo(
220
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale, { month: 'short' }),
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 ? monthNameFmt : valueFmt).format(selectedDate);
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
- /** A month is only unreachable when the WHOLE month falls outside min/max, a range that sits
278
- * inside one month disables both ends while the middle is perfectly selectable. */
279
- function isMonthDisabled(year: number, month: number): boolean {
280
- const first: Date = new Date(year, month, 1);
281
- const last: Date = new Date(year, month + 1, 0);
282
- if (minDate !== null && startOfDay(last).getTime() < startOfDay(minDate).getTime()) return true;
283
- if (maxDate !== null && startOfDay(first).getTime() > startOfDay(maxDate).getTime()) return true;
284
- return false;
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 (isMonthDisabled(year, index)) return;
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
- const next: number = (index + 12) % 12;
398
- setFocusMonth(next);
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 yearRangeLabel: string = `${yearPage} – ${yearPage + YEARS_PER_PAGE - 1}`;
411
-
412
- const yearCells: YearCell[] = useMemo((): YearCell[] => {
413
- const start: number = yearPageStart(focusYear);
414
- const thisYear: number = new Date().getFullYear();
415
- const sel: Date | null = parseValue(props.value);
416
- return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
417
- const year: number = start + index;
418
- return {
419
- year,
420
- selected: sel !== null && sel.getFullYear() === year,
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
- let handled: boolean = true;
452
- switch (e.key) {
453
- case 'ArrowLeft': moveYearFocus(focusYear - 1); break;
454
- case 'ArrowRight': moveYearFocus(focusYear + 1); break;
455
- case 'ArrowUp': moveYearFocus(focusYear - 3); break;
456
- case 'ArrowDown': moveYearFocus(focusYear + 3); break;
457
- case 'Home': moveYearFocus(yearPageStart(focusYear)); break;
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
- let handled: boolean = true;
471
- switch (e.key) {
472
- case 'ArrowLeft': moveMonthFocus(focusMonth - 1, 0); break;
473
- case 'ArrowRight': moveMonthFocus(focusMonth + 1, 0); break;
474
- case 'ArrowUp': moveMonthFocus(focusMonth - 3, 0); break;
475
- case 'ArrowDown': moveMonthFocus(focusMonth + 3, 0); break;
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' ? yearRangeLabel : level === 'months' ? String(viewMonth.getFullYear()) : monthFmt.format(viewMonth)}
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: YearCell) => (
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: MonthCell) => (
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; }}