@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
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
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
  // A Vue-native binding over freeday's `.fdy-datepicker` / `.fdy-cal__*` classes
6
31
  // (see src/components/datepicker.css). freeday ships the styled trigger+popover
@@ -33,7 +58,15 @@ interface DayCell {
33
58
  const props = defineProps<{
34
59
  /** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
35
60
  modelValue: string | null;
36
- /** `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and answers with `YYYY-MM`. */
61
+ /**
62
+ * `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and
63
+ * answers with `YYYY-MM`.
64
+ *
65
+ * @deprecated Use `FdyMonthpicker` for a month. This prop makes `modelValue`, `min` and `max`
66
+ * carry two different formats while a *different* prop decides which, so the type cannot state
67
+ * what to store in them. `FdyMonthpicker` has one format per prop and no mode to read. Still
68
+ * works, unchanged, and removed in 4.0.
69
+ */
37
70
  mode?: FdyDatepickerMode;
38
71
  min?: string;
39
72
  max?: string;
@@ -77,9 +110,6 @@ function pad(n: number): string {
77
110
  function toISO(d: Date): string {
78
111
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
79
112
  }
80
- function toISOMonth(d: Date): string {
81
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
82
- }
83
113
  function parseISO(s: string | null | undefined): Date | null {
84
114
  if (s === null || s === undefined || s === '') return null;
85
115
  const parts: string[] = s.split('-');
@@ -90,35 +120,6 @@ function parseISO(s: string | null | undefined): Date | null {
90
120
  const d: Date = new Date(y, m - 1, day);
91
121
  return Number.isNaN(d.getTime()) ? null : d;
92
122
  }
93
- /** Month mode reads BOTH shapes, and the dropped day is the point: a bound of `2026-03` lands on
94
- * March 1st, which `isMonthDisabled` then compares a whole month against correctly at either end,
95
- * so an app can pass the strings it already stores. A full date still works. */
96
- function parseISOMonth(s: string | null | undefined): Date | null {
97
- if (s === null || s === undefined || s === '') return null;
98
- const parts: string[] = s.split('-');
99
- if (parts.length < 2) return null;
100
- const y: number = Number(parts[0]);
101
- const m: number = Number(parts[1]);
102
- const day: number = parts.length > 2 ? Number(parts[2]) : 1;
103
- const d: Date = new Date(y, m - 1, day);
104
- return Number.isNaN(d.getTime()) ? null : d;
105
- }
106
- /* The page's own `lang`, which is what COMPONENTS.md has promised all along and what the vanilla
107
- enhancer has always done. Handing `undefined` to Intl instead falls back to the BROWSER's
108
- language, so an Indonesian reader saw Indonesian month names inside a kit that is English on
109
- every other path, and an Indonesian PAGE read by an English browser got English ones. That is
110
- the symptom this month mode was asked for, so the fallback has to be the page. */
111
- function pageLocale(): string {
112
- if (typeof document === 'undefined') return 'en';
113
- // `||`, not `??`: an empty `lang=""` is not null and Intl throws RangeError on it.
114
- return document.documentElement.getAttribute('lang') || 'en';
115
- }
116
- function startOfDay(d: Date): Date {
117
- return new Date(d.getFullYear(), d.getMonth(), d.getDate());
118
- }
119
- function startOfMonth(d: Date): Date {
120
- return new Date(d.getFullYear(), d.getMonth(), 1);
121
- }
122
123
  function sameDay(a: Date | null, b: Date | null): boolean {
123
124
  return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
124
125
  }
@@ -127,11 +128,6 @@ function addDays(d: Date, n: number): Date {
127
128
  x.setDate(x.getDate() + n);
128
129
  return x;
129
130
  }
130
- function addMonths(d: Date, n: number): Date {
131
- const x: Date = new Date(d);
132
- x.setMonth(x.getMonth() + n);
133
- return x;
134
- }
135
131
 
136
132
  const baseId: string = useId();
137
133
  const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
@@ -204,95 +200,30 @@ const level: Ref<CalendarLevel> = ref(startLevel());
204
200
  const focusMonth: Ref<number> = ref(0);
205
201
  const focusYear: Ref<number> = ref(0);
206
202
 
207
- /* A page of twelve years, ALIGNED so pages tile: 2016-2027, 2028-2039. Anchoring the page on the
208
- year in view would make the arrows land on overlapping windows, and the same year would sit at a
209
- different spot every time you stepped. */
210
- const YEARS_PER_PAGE = 12;
211
- const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
212
-
213
- const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
214
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'short' }),
203
+ const formatters: ComputedRef<FdyMonthFormatters> = computed(
204
+ (): FdyMonthFormatters => monthFormatters(activeLocale.value),
215
205
  );
216
- const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
217
- (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
218
- );
219
-
220
- interface MonthCell {
221
- index: number;
222
- label: string;
223
- ariaLabel: string;
224
- selected: boolean;
225
- today: boolean;
226
- disabled: boolean;
227
- focusable: boolean;
228
- }
229
206
 
230
- /** A month is only unreachable when the WHOLE month falls outside min/max. Checked against the
231
- * bounds directly, not by testing both ends with isDayDisabled: a range that sits INSIDE one month
232
- * disables both ends while the middle is perfectly selectable. */
233
- function isMonthDisabled(year: number, month: number): boolean {
234
- const first: Date = new Date(year, month, 1);
235
- const last: Date = new Date(year, month + 1, 0);
236
- if (minDate.value !== null && startOfDay(last).getTime() < startOfDay(minDate.value).getTime()) return true;
237
- if (maxDate.value !== null && startOfDay(first).getTime() > startOfDay(maxDate.value).getTime()) return true;
238
- return false;
239
- }
240
-
241
- const monthCells: ComputedRef<MonthCell[]> = computed((): MonthCell[] => {
242
- const year: number = view.value.getFullYear();
243
- const today: Date = new Date();
244
- const sel: Date | null = selectedDate.value;
245
- return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
246
- const cellDate: Date = new Date(year, index, 1);
247
- return {
248
- index,
249
- label: monthCellFmt.value.format(cellDate),
250
- ariaLabel: monthNameFmt.value.format(cellDate),
251
- selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
252
- today: today.getFullYear() === year && today.getMonth() === index,
253
- disabled: isMonthDisabled(year, index),
254
- focusable: index === focusMonth.value,
255
- };
256
- });
257
- });
258
-
259
- interface YearCell {
260
- year: number;
261
- selected: boolean;
262
- today: boolean;
263
- disabled: boolean;
264
- focusable: boolean;
265
- }
266
-
267
- /** A year is unreachable only when EVERY month in it falls outside min/max, the same rule
268
- * isMonthDisabled applies one level down, and for the same reason. */
269
- function isYearDisabled(year: number): boolean {
270
- if (minDate.value !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate.value).getTime()) return true;
271
- if (maxDate.value !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate.value).getTime()) return true;
272
- return false;
273
- }
207
+ const monthCells: ComputedRef<FdyMonthCell[]> = computed((): FdyMonthCell[] => buildMonthCells({
208
+ year: view.value.getFullYear(),
209
+ selected: selectedDate.value,
210
+ min: minDate.value,
211
+ max: maxDate.value,
212
+ focusMonth: focusMonth.value,
213
+ formatters: formatters.value,
214
+ }));
274
215
 
275
216
  const yearPage: ComputedRef<number> = computed((): number => yearPageStart(focusYear.value));
276
217
 
277
- const yearCells: ComputedRef<YearCell[]> = computed((): YearCell[] => {
278
- const start: number = yearPage.value;
279
- const thisYear: number = new Date().getFullYear();
280
- const sel: Date | null = selectedDate.value;
281
- return Array.from({ length: YEARS_PER_PAGE }, (_unused: unknown, index: number): YearCell => {
282
- const year: number = start + index;
283
- return {
284
- year,
285
- selected: sel !== null && sel.getFullYear() === year,
286
- today: thisYear === year,
287
- disabled: isYearDisabled(year),
288
- focusable: year === focusYear.value,
289
- };
290
- });
291
- });
218
+ const yearCells: ComputedRef<FdyYearCell[]> = computed((): FdyYearCell[] => buildYearCells({
219
+ pageStart: yearPage.value,
220
+ selected: selectedDate.value,
221
+ min: minDate.value,
222
+ max: maxDate.value,
223
+ focusYear: focusYear.value,
224
+ }));
292
225
 
293
- const yearRangeLabel: ComputedRef<string> = computed(
294
- (): string => `${yearPage.value} – ${yearPage.value + YEARS_PER_PAGE - 1}`,
295
- );
226
+ const yearRangeText: ComputedRef<string> = computed((): string => yearRangeLabel(yearPage.value));
296
227
 
297
228
  const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
298
229
  (): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
@@ -312,7 +243,7 @@ const dowFmt: ComputedRef<Intl.DateTimeFormat> = computed(
312
243
  browser's language, this names it in the page's. */
313
244
  const displayValue: ComputedRef<string> = computed((): string => {
314
245
  if (selectedDate.value === null) return displayPlaceholder.value;
315
- const fmt: Intl.DateTimeFormat = isMonthMode.value ? monthNameFmt.value : valueFmt.value;
246
+ const fmt: Intl.DateTimeFormat = isMonthMode.value ? formatters.value.name : valueFmt.value;
316
247
  return fmt.format(selectedDate.value);
317
248
  });
318
249
 
@@ -385,7 +316,7 @@ function chooseMonth(index: number): void {
385
316
 
386
317
  function pickMonth(index: number): void {
387
318
  const year: number = view.value.getFullYear();
388
- if (isMonthDisabled(year, index)) return;
319
+ if (monthDisabled(year, index, minDate.value, maxDate.value)) return;
389
320
  const first: Date = new Date(year, index, 1);
390
321
  focusMonth.value = index;
391
322
  focusDate.value = first;
@@ -434,47 +365,29 @@ function moveYearFocus(year: number): void {
434
365
  }
435
366
 
436
367
  function onYearKeydown(e: KeyboardEvent): void {
437
- let handled: boolean = true;
438
- switch (e.key) {
439
- case 'ArrowLeft': moveYearFocus(focusYear.value - 1); break;
440
- case 'ArrowRight': moveYearFocus(focusYear.value + 1); break;
441
- case 'ArrowUp': moveYearFocus(focusYear.value - 3); break;
442
- case 'ArrowDown': moveYearFocus(focusYear.value + 3); break;
443
- case 'Home': moveYearFocus(yearPageStart(focusYear.value)); break;
444
- case 'End': moveYearFocus(yearPageStart(focusYear.value) + YEARS_PER_PAGE - 1); break;
445
- case 'PageUp': moveYearFocus(focusYear.value - YEARS_PER_PAGE); break;
446
- case 'PageDown': moveYearFocus(focusYear.value + YEARS_PER_PAGE); break;
447
- case 'Enter':
448
- case ' ': openYear(focusYear.value); break;
449
- case 'Escape': closePanel(true); break;
450
- default: handled = false;
451
- }
452
- if (handled) { e.preventDefault(); e.stopPropagation(); }
368
+ const intent = yearKeyIntent(e.key, focusYear.value);
369
+ if (intent.action === 'none') return;
370
+ e.preventDefault();
371
+ e.stopPropagation();
372
+ if (intent.action === 'focus') moveYearFocus(intent.year);
373
+ else if (intent.action === 'choose') openYear(intent.year);
374
+ else closePanel(true);
453
375
  }
454
376
 
377
+ /** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
455
378
  function moveMonthFocus(index: number, yearDelta: number): void {
456
379
  if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
457
- focusMonth.value = (index + 12) % 12;
380
+ focusMonth.value = index;
458
381
  void nextTick((): void => focusMonthCell());
459
382
  }
460
383
 
461
384
  function onMonthKeydown(e: KeyboardEvent): void {
462
- let handled: boolean = true;
463
- switch (e.key) {
464
- case 'ArrowLeft': moveMonthFocus(focusMonth.value - 1, 0); break;
465
- case 'ArrowRight': moveMonthFocus(focusMonth.value + 1, 0); break;
466
- case 'ArrowUp': moveMonthFocus(focusMonth.value - 3, 0); break;
467
- case 'ArrowDown': moveMonthFocus(focusMonth.value + 3, 0); break;
468
- case 'Home': moveMonthFocus(0, 0); break;
469
- case 'End': moveMonthFocus(11, 0); break;
470
- case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
471
- case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
472
- case 'Enter':
473
- case ' ': chooseMonth(focusMonth.value); break;
474
- case 'Escape': closePanel(true); break;
475
- default: handled = false;
476
- }
477
- if (handled) e.preventDefault();
385
+ const intent = monthKeyIntent(e.key, focusMonth.value);
386
+ if (intent.action === 'none') return;
387
+ e.preventDefault();
388
+ if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
389
+ else if (intent.action === 'choose') chooseMonth(intent.index);
390
+ else closePanel(true);
478
391
  }
479
392
 
480
393
  function pick(d: Date): void {
@@ -651,7 +564,7 @@ onBeforeUnmount((): void => {
651
564
  :aria-label="level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText"
652
565
  @click="
653
566
  level === 'years'
654
- ? moveYearFocus(focusYear - 12)
567
+ ? moveYearFocus(focusYear - YEARS_PER_PAGE)
655
568
  : level === 'months'
656
569
  ? moveMonthFocus(focusMonth, -1)
657
570
  : (view = addMonths(view, -1))
@@ -663,14 +576,14 @@ onBeforeUnmount((): void => {
663
576
  class="fdy-cal__title"
664
577
  :aria-label="level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText"
665
578
  @click="level === 'months' ? openYearGrid() : openMonthGrid()"
666
- >{{ level === 'years' ? yearRangeLabel : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
579
+ >{{ level === 'years' ? yearRangeText : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
667
580
  <button
668
581
  type="button"
669
582
  class="fdy-cal__nav"
670
583
  :aria-label="level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText"
671
584
  @click="
672
585
  level === 'years'
673
- ? moveYearFocus(focusYear + 12)
586
+ ? moveYearFocus(focusYear + YEARS_PER_PAGE)
674
587
  : level === 'months'
675
588
  ? moveMonthFocus(focusMonth, 1)
676
589
  : (view = addMonths(view, 1))