@cahyo-dimas/freeday 3.7.0 → 3.8.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 +93 -0
- package/COMPONENTS.md +119 -6
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/adapters/blazor/FdyDatepicker.razor +3 -1
- package/adapters/blazor/FdyDatepicker.razor.cs +22 -1
- package/adapters/blazor/FdyTableFooter.razor +6 -6
- package/adapters/blazor/FdyTableFooter.razor.cs +36 -0
- package/adapters/react/components/FdyDatepicker.tsx +122 -42
- package/adapters/react/components/FdyTableFooter.tsx +37 -6
- package/adapters/react/index.d.ts +1 -1
- package/adapters/vue/components/FdyDatepicker.vue +119 -34
- package/adapters/vue/components/FdyTableFooter.vue +39 -6
- package/dist/freeday-datepicker.js +68 -14
- package/dist/freeday-menu.js +15 -1
- package/dist/freeday-popover.js +33 -11
- package/dist/freeday-tabs.js +21 -2
- package/dist/freeday.bundle.css +31 -0
- package/dist/freeday.css +31 -0
- package/dist/freeday.js +137 -28
- package/docs/getting-started.md +1 -1
- package/package.json +2 -2
- package/src/components/tabs.css +31 -0
|
@@ -12,6 +12,12 @@ import { usePopover } from '../usePopover';
|
|
|
12
12
|
// over the same kit classes. Open/close (outside-click + focusout) mirrors the
|
|
13
13
|
// already-approved FdyCombo.vue pattern. Range mode is out of scope (v1 = single date).
|
|
14
14
|
|
|
15
|
+
/** Which grid is on screen. The drill goes days -> months -> years and back down. */
|
|
16
|
+
type CalendarLevel = 'days' | 'months' | 'years';
|
|
17
|
+
|
|
18
|
+
/** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
|
|
19
|
+
type FdyDatepickerMode = 'day' | 'month';
|
|
20
|
+
|
|
15
21
|
interface DayCell {
|
|
16
22
|
date: Date;
|
|
17
23
|
iso: string;
|
|
@@ -25,7 +31,10 @@ interface DayCell {
|
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
const props = defineProps<{
|
|
34
|
+
/** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
|
|
28
35
|
modelValue: string | null;
|
|
36
|
+
/** `'day'` (default) answers with a date. `'month'` opens on the month grid, stops there, and answers with `YYYY-MM`. */
|
|
37
|
+
mode?: FdyDatepickerMode;
|
|
29
38
|
min?: string;
|
|
30
39
|
max?: string;
|
|
31
40
|
locale?: string;
|
|
@@ -68,6 +77,9 @@ function pad(n: number): string {
|
|
|
68
77
|
function toISO(d: Date): string {
|
|
69
78
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
70
79
|
}
|
|
80
|
+
function toISOMonth(d: Date): string {
|
|
81
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
82
|
+
}
|
|
71
83
|
function parseISO(s: string | null | undefined): Date | null {
|
|
72
84
|
if (s === null || s === undefined || s === '') return null;
|
|
73
85
|
const parts: string[] = s.split('-');
|
|
@@ -78,6 +90,29 @@ function parseISO(s: string | null | undefined): Date | null {
|
|
|
78
90
|
const d: Date = new Date(y, m - 1, day);
|
|
79
91
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
80
92
|
}
|
|
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
|
+
}
|
|
81
116
|
function startOfDay(d: Date): Date {
|
|
82
117
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
83
118
|
}
|
|
@@ -113,13 +148,22 @@ const open: Ref<boolean> = ref(false);
|
|
|
113
148
|
// scroll container; positions against the trigger.
|
|
114
149
|
usePopover(panelEl, triggerEl, open);
|
|
115
150
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
151
|
+
const isMonthMode: ComputedRef<boolean> = computed((): boolean => props.mode === 'month');
|
|
152
|
+
/** Resolved once here so every formatter below reads the same tag. */
|
|
153
|
+
const activeLocale: ComputedRef<string> = computed((): string => props.locale ?? pageLocale());
|
|
154
|
+
/** Reading `props.mode` inside the computeds below is what keeps them in step with it. */
|
|
155
|
+
function parseValue(s: string | null | undefined): Date | null {
|
|
156
|
+
return props.mode === 'month' ? parseISOMonth(s) : parseISO(s);
|
|
157
|
+
}
|
|
158
|
+
const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.modelValue));
|
|
159
|
+
const minDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.min));
|
|
160
|
+
const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.max));
|
|
119
161
|
const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
|
|
120
162
|
const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
|
|
121
163
|
const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
|
|
122
|
-
const displayPlaceholder: ComputedRef<string> = computed(
|
|
164
|
+
const displayPlaceholder: ComputedRef<string> = computed(
|
|
165
|
+
(): string => props.placeholder ?? (isMonthMode.value ? 'Select month' : 'Select date'),
|
|
166
|
+
);
|
|
123
167
|
const showClear: ComputedRef<boolean> = computed(
|
|
124
168
|
(): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
|
|
125
169
|
);
|
|
@@ -145,10 +189,18 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
|
|
|
145
189
|
return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
|
|
146
190
|
});
|
|
147
191
|
|
|
148
|
-
/*
|
|
149
|
-
Before this the only pointer route to another month was one click per month: August
|
|
150
|
-
2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all.
|
|
151
|
-
|
|
192
|
+
/* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
|
|
193
|
+
the title. Before this the only pointer route to another month was one click per month: August
|
|
194
|
+
2026 to March 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all.
|
|
195
|
+
Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with, and one
|
|
196
|
+
name for both would read as the same thing in a template that mentions them together.
|
|
197
|
+
|
|
198
|
+
Month mode has no level below months, so the day grid is unreachable by construction rather
|
|
199
|
+
than by every path remembering not to ask for it. */
|
|
200
|
+
function startLevel(): CalendarLevel {
|
|
201
|
+
return props.mode === 'month' ? 'months' : 'days';
|
|
202
|
+
}
|
|
203
|
+
const level: Ref<CalendarLevel> = ref(startLevel());
|
|
152
204
|
const focusMonth: Ref<number> = ref(0);
|
|
153
205
|
const focusYear: Ref<number> = ref(0);
|
|
154
206
|
|
|
@@ -159,10 +211,10 @@ const YEARS_PER_PAGE = 12;
|
|
|
159
211
|
const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
|
|
160
212
|
|
|
161
213
|
const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
162
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
214
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'short' }),
|
|
163
215
|
);
|
|
164
216
|
const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
165
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
217
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
|
|
166
218
|
);
|
|
167
219
|
|
|
168
220
|
interface MonthCell {
|
|
@@ -243,21 +295,26 @@ const yearRangeLabel: ComputedRef<string> = computed(
|
|
|
243
295
|
);
|
|
244
296
|
|
|
245
297
|
const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
246
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
298
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
|
|
247
299
|
);
|
|
248
300
|
const valueFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
249
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
301
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { day: '2-digit', month: 'short', year: 'numeric' }),
|
|
250
302
|
);
|
|
251
303
|
const dayLabelFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
252
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
304
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
|
|
253
305
|
);
|
|
254
306
|
const dowFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
255
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
307
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'short' }),
|
|
256
308
|
);
|
|
257
309
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
310
|
+
/* Month mode shows the month through the SAME formatter the month cells and the day grid's title
|
|
311
|
+
already use, which is the whole point of the mode: <input type="month"> names the month in the
|
|
312
|
+
browser's language, this names it in the page's. */
|
|
313
|
+
const displayValue: ComputedRef<string> = computed((): string => {
|
|
314
|
+
if (selectedDate.value === null) return displayPlaceholder.value;
|
|
315
|
+
const fmt: Intl.DateTimeFormat = isMonthMode.value ? monthNameFmt.value : valueFmt.value;
|
|
316
|
+
return fmt.format(selectedDate.value);
|
|
317
|
+
});
|
|
261
318
|
|
|
262
319
|
const weekdayHeaders: ComputedRef<string[]> = computed((): string[] => {
|
|
263
320
|
const monday: Date = new Date(2024, 0, 1); // a known Monday
|
|
@@ -310,7 +367,7 @@ function moveFocus(next: Date): void {
|
|
|
310
367
|
|
|
311
368
|
function openMonthGrid(): void {
|
|
312
369
|
focusMonth.value = view.value.getMonth();
|
|
313
|
-
|
|
370
|
+
level.value = 'months';
|
|
314
371
|
void nextTick((): void => focusMonthCell());
|
|
315
372
|
}
|
|
316
373
|
|
|
@@ -319,6 +376,25 @@ function focusMonthCell(): void {
|
|
|
319
376
|
cell?.focus();
|
|
320
377
|
}
|
|
321
378
|
|
|
379
|
+
/* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
|
|
380
|
+
day mode a month is a step on the way down; in month mode it is the answer. */
|
|
381
|
+
function chooseMonth(index: number): void {
|
|
382
|
+
if (isMonthMode.value) pickMonth(index);
|
|
383
|
+
else openMonth(index);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function pickMonth(index: number): void {
|
|
387
|
+
const year: number = view.value.getFullYear();
|
|
388
|
+
if (isMonthDisabled(year, index)) return;
|
|
389
|
+
const first: Date = new Date(year, index, 1);
|
|
390
|
+
focusMonth.value = index;
|
|
391
|
+
focusDate.value = first;
|
|
392
|
+
const iso: string = toISOMonth(first);
|
|
393
|
+
emit('update:modelValue', iso);
|
|
394
|
+
emit('change', iso);
|
|
395
|
+
closePanel(true);
|
|
396
|
+
}
|
|
397
|
+
|
|
322
398
|
/* Picking a month is NAVIGATION, not selection: it drops back to the day grid with the roving cell
|
|
323
399
|
clamped into the new month, so nothing is committed until a day is chosen. */
|
|
324
400
|
function openMonth(index: number): void {
|
|
@@ -327,13 +403,13 @@ function openMonth(index: number): void {
|
|
|
327
403
|
const day: number = Math.min(focusDate.value.getDate(), daysInMonth);
|
|
328
404
|
view.value = new Date(year, index, 1);
|
|
329
405
|
focusDate.value = new Date(year, index, day);
|
|
330
|
-
|
|
406
|
+
level.value = 'days';
|
|
331
407
|
void nextTick((): void => focusCell());
|
|
332
408
|
}
|
|
333
409
|
|
|
334
410
|
function openYearGrid(): void {
|
|
335
411
|
focusYear.value = view.value.getFullYear();
|
|
336
|
-
|
|
412
|
+
level.value = 'years';
|
|
337
413
|
void nextTick((): void => focusYearCell());
|
|
338
414
|
}
|
|
339
415
|
|
|
@@ -347,7 +423,7 @@ function focusYearCell(): void {
|
|
|
347
423
|
function openYear(year: number): void {
|
|
348
424
|
view.value = new Date(year, view.value.getMonth(), 1);
|
|
349
425
|
focusMonth.value = view.value.getMonth();
|
|
350
|
-
|
|
426
|
+
level.value = 'months';
|
|
351
427
|
void nextTick((): void => focusMonthCell());
|
|
352
428
|
}
|
|
353
429
|
|
|
@@ -394,7 +470,7 @@ function onMonthKeydown(e: KeyboardEvent): void {
|
|
|
394
470
|
case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
|
|
395
471
|
case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
|
|
396
472
|
case 'Enter':
|
|
397
|
-
case ' ':
|
|
473
|
+
case ' ': chooseMonth(focusMonth.value); break;
|
|
398
474
|
case 'Escape': closePanel(true); break;
|
|
399
475
|
default: handled = false;
|
|
400
476
|
}
|
|
@@ -423,7 +499,16 @@ function openPanel(): void {
|
|
|
423
499
|
if (isDisabled.value || isReadonly.value || open.value) return;
|
|
424
500
|
focusDate.value = selectedDate.value ?? focusDate.value ?? new Date();
|
|
425
501
|
view.value = new Date(focusDate.value.getFullYear(), focusDate.value.getMonth(), 1);
|
|
502
|
+
/* Reopening starts at the mode's own floor. Without this a month picker that drilled to the year
|
|
503
|
+
grid and was dismissed would open on years the next time, and a keyboard user would land on a
|
|
504
|
+
grid that cannot answer. */
|
|
505
|
+
level.value = startLevel();
|
|
426
506
|
open.value = true;
|
|
507
|
+
if (isMonthMode.value) {
|
|
508
|
+
focusMonth.value = view.value.getMonth();
|
|
509
|
+
void nextTick((): void => focusMonthCell());
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
427
512
|
void nextTick((): void => focusCell());
|
|
428
513
|
}
|
|
429
514
|
|
|
@@ -563,11 +648,11 @@ onBeforeUnmount((): void => {
|
|
|
563
648
|
<button
|
|
564
649
|
type="button"
|
|
565
650
|
class="fdy-cal__nav"
|
|
566
|
-
:aria-label="
|
|
651
|
+
:aria-label="level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText"
|
|
567
652
|
@click="
|
|
568
|
-
|
|
653
|
+
level === 'years'
|
|
569
654
|
? moveYearFocus(focusYear - 12)
|
|
570
|
-
:
|
|
655
|
+
: level === 'months'
|
|
571
656
|
? moveMonthFocus(focusMonth, -1)
|
|
572
657
|
: (view = addMonths(view, -1))
|
|
573
658
|
"
|
|
@@ -576,23 +661,23 @@ onBeforeUnmount((): void => {
|
|
|
576
661
|
:id="titleId"
|
|
577
662
|
type="button"
|
|
578
663
|
class="fdy-cal__title"
|
|
579
|
-
:aria-label="
|
|
580
|
-
@click="
|
|
581
|
-
>{{
|
|
664
|
+
:aria-label="level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText"
|
|
665
|
+
@click="level === 'months' ? openYearGrid() : openMonthGrid()"
|
|
666
|
+
>{{ level === 'years' ? yearRangeLabel : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
|
|
582
667
|
<button
|
|
583
668
|
type="button"
|
|
584
669
|
class="fdy-cal__nav"
|
|
585
|
-
:aria-label="
|
|
670
|
+
:aria-label="level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText"
|
|
586
671
|
@click="
|
|
587
|
-
|
|
672
|
+
level === 'years'
|
|
588
673
|
? moveYearFocus(focusYear + 12)
|
|
589
|
-
:
|
|
674
|
+
: level === 'months'
|
|
590
675
|
? moveMonthFocus(focusMonth, 1)
|
|
591
676
|
: (view = addMonths(view, 1))
|
|
592
677
|
"
|
|
593
678
|
>›</button>
|
|
594
679
|
</div>
|
|
595
|
-
<div v-if="
|
|
680
|
+
<div v-if="level === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
|
|
596
681
|
<button
|
|
597
682
|
v-for="cell in yearCells"
|
|
598
683
|
:key="cell.year"
|
|
@@ -607,7 +692,7 @@ onBeforeUnmount((): void => {
|
|
|
607
692
|
@click="openYear(cell.year)"
|
|
608
693
|
>{{ cell.year }}</button>
|
|
609
694
|
</div>
|
|
610
|
-
<div v-else-if="
|
|
695
|
+
<div v-else-if="level === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
|
|
611
696
|
<button
|
|
612
697
|
v-for="cell in monthCells"
|
|
613
698
|
:key="cell.index"
|
|
@@ -619,7 +704,7 @@ onBeforeUnmount((): void => {
|
|
|
619
704
|
:aria-selected="cell.selected ? 'true' : undefined"
|
|
620
705
|
:disabled="cell.disabled"
|
|
621
706
|
:tabindex="cell.focusable ? 0 : -1"
|
|
622
|
-
@click="
|
|
707
|
+
@click="chooseMonth(cell.index)"
|
|
623
708
|
>{{ cell.label }}</button>
|
|
624
709
|
</div>
|
|
625
710
|
<div v-else class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">
|
|
@@ -25,6 +25,28 @@ const props = defineProps<{
|
|
|
25
25
|
* holds the row you were looking at.
|
|
26
26
|
*/
|
|
27
27
|
pageSizes?: readonly number[];
|
|
28
|
+
/* The strings, six of them, one per thing this band says. The raw path has had a hatch since
|
|
29
|
+
* 1.39.0 (`data-fdy-text-<key>` read through `textOf()`) and this path had none, so an app that
|
|
30
|
+
* does not speak English could only rewrite the whole band by hand; one shipped eighty one lines
|
|
31
|
+
* of its own markup to change five words. Names follow the enhancer's `TEXT` keys where a key
|
|
32
|
+
* exists, `info`, `prev`, `next`, so one string is not two names across the two paths. The other
|
|
33
|
+
* three have no twin because on the raw path that markup is the app's own. Defaults stay English
|
|
34
|
+
* on every path, which COMPONENTS.md promises and the suite enforces. */
|
|
35
|
+
/**
|
|
36
|
+
* The range line. `{from}`, `{to}` and `{total}` are substituted, every occurrence, as `textOf()`
|
|
37
|
+
* does. Default `Showing {from}–{to} of {total}`, the enhancer's `info`.
|
|
38
|
+
*/
|
|
39
|
+
infoText?: string;
|
|
40
|
+
/** The visible word beside the rows-per-page control, which also names it. Default `Rows`. */
|
|
41
|
+
pageSizeLabel?: string;
|
|
42
|
+
/** Accessible name of the pager landmark. Default `Pagination`. */
|
|
43
|
+
paginationLabel?: string;
|
|
44
|
+
/** Accessible name of the previous-page arrow. Default `Previous page`. */
|
|
45
|
+
prevLabel?: string;
|
|
46
|
+
/** Accessible name of the next-page arrow. Default `Next page`. */
|
|
47
|
+
nextLabel?: string;
|
|
48
|
+
/** Accessible name of a numbered page button. `{n}` substituted. Default `Go to page {n}`. */
|
|
49
|
+
pageLabel?: string;
|
|
28
50
|
}>();
|
|
29
51
|
|
|
30
52
|
const emit = defineEmits<{ 'update:page': [page: FdyPageState] }>();
|
|
@@ -47,6 +69,13 @@ const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number
|
|
|
47
69
|
pageWindow(currentPage1.value, totalPages.value),
|
|
48
70
|
);
|
|
49
71
|
|
|
72
|
+
const infoLine: ComputedRef<string> = computed((): string =>
|
|
73
|
+
(props.infoText ?? 'Showing {from}–{to} of {total}')
|
|
74
|
+
.replace(/\{from\}/g, String(rangeFrom.value))
|
|
75
|
+
.replace(/\{to\}/g, String(rangeTo.value))
|
|
76
|
+
.replace(/\{total\}/g, String(props.page.total)),
|
|
77
|
+
);
|
|
78
|
+
|
|
50
79
|
const hasPager: ComputedRef<boolean> = computed((): boolean => props.page.size > 0 && totalPages.value > 1);
|
|
51
80
|
const sizes: ComputedRef<readonly number[]> = computed((): readonly number[] => props.pageSizes ?? []);
|
|
52
81
|
/* Just the number. The control sits beside a label reading "Rows", so "20 per page" states the same
|
|
@@ -58,6 +87,10 @@ const sizeOptions: ComputedRef<Array<{ value: string; label: string }>> = comput
|
|
|
58
87
|
* the whole band in that case, and this is where that decision now lives. */
|
|
59
88
|
const visible: ComputedRef<boolean> = computed((): boolean => hasPager.value || sizes.value.length > 0);
|
|
60
89
|
|
|
90
|
+
function pageAriaLabel(page1: number): string {
|
|
91
|
+
return (props.pageLabel ?? 'Go to page {n}').replace(/\{n\}/g, String(page1));
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
function goTo(page1: number): void {
|
|
62
95
|
const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
|
|
63
96
|
emit('update:page', { index: clamped - 1, size: props.page.size, total: props.page.total });
|
|
@@ -76,10 +109,10 @@ function onSize(value: string): void {
|
|
|
76
109
|
|
|
77
110
|
<template>
|
|
78
111
|
<div v-if="visible" class="fdy-table-footer">
|
|
79
|
-
<span class="fdy-table-footer__info">
|
|
112
|
+
<span class="fdy-table-footer__info">{{ infoLine }}</span>
|
|
80
113
|
|
|
81
114
|
<div v-if="sizes.length > 0" class="fdy-table-footer__size">
|
|
82
|
-
<span :id="sizeLabelId">Rows</span>
|
|
115
|
+
<span :id="sizeLabelId">{{ pageSizeLabel ?? 'Rows' }}</span>
|
|
83
116
|
<!-- The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS it
|
|
84
117
|
drops a dark panel into a light page. FdyCombo's own header says exactly that. -->
|
|
85
118
|
<FdyCombo
|
|
@@ -90,13 +123,13 @@ function onSize(value: string): void {
|
|
|
90
123
|
/>
|
|
91
124
|
</div>
|
|
92
125
|
|
|
93
|
-
<nav v-if="hasPager" class="fdy-pagination" aria-label="Pagination">
|
|
126
|
+
<nav v-if="hasPager" class="fdy-pagination" :aria-label="paginationLabel ?? 'Pagination'">
|
|
94
127
|
<ul class="fdy-pagination__list">
|
|
95
128
|
<li>
|
|
96
129
|
<button
|
|
97
130
|
type="button"
|
|
98
131
|
class="fdy-pagination__link"
|
|
99
|
-
aria-label="Previous page"
|
|
132
|
+
:aria-label="prevLabel ?? 'Previous page'"
|
|
100
133
|
:disabled="currentPage1 === 1"
|
|
101
134
|
@click="goTo(currentPage1 - 1)"
|
|
102
135
|
>‹</button>
|
|
@@ -104,13 +137,13 @@ function onSize(value: string): void {
|
|
|
104
137
|
<li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
|
|
105
138
|
<span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
|
|
106
139
|
<span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
|
|
107
|
-
<button v-else type="button" class="fdy-pagination__link" :aria-label="
|
|
140
|
+
<button v-else type="button" class="fdy-pagination__link" :aria-label="pageAriaLabel(p)" @click="goTo(p)">{{ p }}</button>
|
|
108
141
|
</li>
|
|
109
142
|
<li>
|
|
110
143
|
<button
|
|
111
144
|
type="button"
|
|
112
145
|
class="fdy-pagination__link"
|
|
113
|
-
aria-label="Next page"
|
|
146
|
+
:aria-label="nextLabel ?? 'Next page'"
|
|
114
147
|
:disabled="currentPage1 === totalPages"
|
|
115
148
|
@click="goTo(currentPage1 + 1)"
|
|
116
149
|
>›</button>
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* <div data-fdy-datepicker data-role="to" data-placeholder="To"></div>
|
|
12
12
|
* </div>
|
|
13
13
|
* The range links the two: the end can never precede the start (out-of-range days disable).
|
|
14
|
+
* - Month: <div data-fdy-datepicker data-mode="month" data-value="2026-10"></div>
|
|
15
|
+
* The calendar opens ON the month grid and stops there; the value is "YYYY-MM".
|
|
14
16
|
*
|
|
15
17
|
* Emits a bubbling "fdy-datepicker-change" CustomEvent on the wrapper: detail {value, date}.
|
|
16
18
|
* Keyboard in the grid: ←/→ ±1 day, ↑/↓ ±1 week, Home/End week edge, PageUp/Down ±month,
|
|
@@ -39,6 +41,11 @@
|
|
|
39
41
|
var TEXT = {
|
|
40
42
|
label: 'Date',
|
|
41
43
|
placeholder: 'Choose a date',
|
|
44
|
+
/* The month mode's own pair. Reusing `label` and `placeholder` would name a month field "Date"
|
|
45
|
+
and ask the reader to "Choose a date" on a control that cannot express a day, so every caller
|
|
46
|
+
would have to pass both attributes to undo a default that was simply wrong for the mode. */
|
|
47
|
+
labelMonth: 'Month',
|
|
48
|
+
placeholderMonth: 'Choose a month',
|
|
42
49
|
prevMonth: 'Previous month',
|
|
43
50
|
nextMonth: 'Next month',
|
|
44
51
|
prevYear: 'Previous year',
|
|
@@ -69,6 +76,7 @@
|
|
|
69
76
|
function uid(p) { uidSeq += 1; return p + '-' + uidSeq; }
|
|
70
77
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
|
71
78
|
function toISO(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
|
|
79
|
+
function toISOMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1); }
|
|
72
80
|
function parseISO(s) {
|
|
73
81
|
if (!s) return null;
|
|
74
82
|
var p = String(s).split('-');
|
|
@@ -76,6 +84,17 @@
|
|
|
76
84
|
var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
|
77
85
|
return isNaN(d.getTime()) ? null : d;
|
|
78
86
|
}
|
|
87
|
+
/* Month mode reads BOTH shapes, and the day it drops is the point: a bound of "2026-03" lands on
|
|
88
|
+
March 1st, and monthDisabled then compares a whole month against it correctly at either end,
|
|
89
|
+
so an app can pass the same strings it stores. A full date still works for a host that keeps
|
|
90
|
+
its bounds in one format across a form. */
|
|
91
|
+
function parseISOMonth(s) {
|
|
92
|
+
if (!s) return null;
|
|
93
|
+
var p = String(s).split('-');
|
|
94
|
+
if (p.length < 2) return null;
|
|
95
|
+
var d = new Date(Number(p[0]), Number(p[1]) - 1, p.length > 2 ? Number(p[2]) : 1);
|
|
96
|
+
return isNaN(d.getTime()) ? null : d;
|
|
97
|
+
}
|
|
79
98
|
function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
|
|
80
99
|
function sameDay(a, b) { return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
|
|
81
100
|
function addDays(d, n) { var x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
|
@@ -102,11 +121,16 @@
|
|
|
102
121
|
wrap.dataset.fdyDpReady = '1';
|
|
103
122
|
wrap.classList.add('fdy-datepicker');
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
var
|
|
109
|
-
var
|
|
124
|
+
/* `data-mode="month"` makes the month grid the DESTINATION rather than a waypoint: the panel
|
|
125
|
+
opens on it, Enter and a click commit from it, the day grid is never built, and the value is
|
|
126
|
+
"YYYY-MM". Anything else, including the attribute's absence, is the day picker unchanged. */
|
|
127
|
+
var monthOnly = wrap.getAttribute('data-mode') === 'month';
|
|
128
|
+
var parseVal = monthOnly ? parseISOMonth : parseISO;
|
|
129
|
+
var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, monthOnly ? 'placeholderMonth' : 'placeholder');
|
|
130
|
+
var label = wrap.getAttribute('data-label') || textOf(wrap, monthOnly ? 'labelMonth' : 'label');
|
|
131
|
+
var selected = parseVal(wrap.getAttribute('data-value'));
|
|
132
|
+
var minDate = parseVal(wrap.getAttribute('data-min'));
|
|
133
|
+
var maxDate = parseVal(wrap.getAttribute('data-max'));
|
|
110
134
|
var rangeStart = null, rangeEnd = null; // for in-range shading
|
|
111
135
|
var view = startOfDay(selected || new Date());
|
|
112
136
|
view = new Date(view.getFullYear(), view.getMonth(), 1);
|
|
@@ -116,7 +140,7 @@
|
|
|
116
140
|
August 2026 to March 2022 is fifty-three of them. Shift+PageUp already jumped a year, but a
|
|
117
141
|
shortcut nobody can see is not an affordance, the repo's own test helper clicked "previous
|
|
118
142
|
month" twenty-four times to reach a date two years back. */
|
|
119
|
-
var mode = 'days';
|
|
143
|
+
var mode = monthOnly ? 'months' : 'days';
|
|
120
144
|
var focusMonth = null;
|
|
121
145
|
var focusYear = null;
|
|
122
146
|
|
|
@@ -152,7 +176,10 @@
|
|
|
152
176
|
|
|
153
177
|
function updateDisplay() {
|
|
154
178
|
if (selected) {
|
|
155
|
-
|
|
179
|
+
/* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
|
|
180
|
+
which is the whole reason this mode exists: <input type="month"> names the month in the
|
|
181
|
+
browser's language, this names it in the page's. */
|
|
182
|
+
valueSpan.textContent = (monthOnly ? monthNameFmt : valueFmt).format(selected);
|
|
156
183
|
valueSpan.classList.remove('fdy-datepicker__value--placeholder');
|
|
157
184
|
} else {
|
|
158
185
|
valueSpan.textContent = placeholder;
|
|
@@ -196,6 +223,10 @@
|
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
function render() {
|
|
226
|
+
/* The floor of the drill, stated once here rather than at every caller that could lower it:
|
|
227
|
+
in month mode there is no level below months, so the day grid is unreachable by
|
|
228
|
+
construction instead of by every path remembering not to ask for it. */
|
|
229
|
+
if (monthOnly && mode === 'days') mode = 'months';
|
|
199
230
|
if (mode === 'years') { renderYears(); return; }
|
|
200
231
|
if (mode === 'months') { renderMonths(); return; }
|
|
201
232
|
panel.innerHTML = '';
|
|
@@ -300,7 +331,7 @@
|
|
|
300
331
|
}
|
|
301
332
|
if (monthDisabled(year, m)) btn.disabled = true;
|
|
302
333
|
btn.tabIndex = m === focusMonth ? 0 : -1;
|
|
303
|
-
btn.addEventListener('click', (function (mm) { return function () {
|
|
334
|
+
btn.addEventListener('click', (function (mm) { return function () { chooseMonth(mm); }; })(m));
|
|
304
335
|
grid.appendChild(btn);
|
|
305
336
|
}
|
|
306
337
|
grid.addEventListener('keydown', onMonthKey);
|
|
@@ -414,6 +445,28 @@
|
|
|
414
445
|
if (cell) cell.focus();
|
|
415
446
|
}
|
|
416
447
|
|
|
448
|
+
/* What a month cell DOES, decided in one place for the click handler and the Enter key alike.
|
|
449
|
+
In day mode a month is a step on the way down; in month mode it is the answer. */
|
|
450
|
+
function chooseMonth(m) {
|
|
451
|
+
if (monthOnly) pickMonth(m);
|
|
452
|
+
else openMonth(m);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function pickMonth(m) {
|
|
456
|
+
var year = view.getFullYear();
|
|
457
|
+
if (monthDisabled(year, m)) return;
|
|
458
|
+
selected = new Date(year, m, 1);
|
|
459
|
+
focusMonth = m;
|
|
460
|
+
focusDate = selected;
|
|
461
|
+
updateDisplay();
|
|
462
|
+
/* The same event name, so a host already listening keeps listening, and `date` is still a
|
|
463
|
+
real Date (the first of the month) rather than a second shape to parse. */
|
|
464
|
+
wrap.dispatchEvent(new CustomEvent('fdy-datepicker-change', {
|
|
465
|
+
bubbles: true, detail: { value: toISOMonth(selected), date: new Date(selected) }
|
|
466
|
+
}));
|
|
467
|
+
close(true);
|
|
468
|
+
}
|
|
469
|
+
|
|
417
470
|
/* Picking a month is navigation, not selection: it drops back to the day grid with the roving
|
|
418
471
|
cell clamped into the new month, so nothing is committed until a DAY is chosen. */
|
|
419
472
|
function openMonth(m) {
|
|
@@ -445,7 +498,7 @@
|
|
|
445
498
|
case 'PageUp': moveMonthFocus(focusMonth, -1); break;
|
|
446
499
|
case 'PageDown': moveMonthFocus(focusMonth, 1); break;
|
|
447
500
|
case 'Enter':
|
|
448
|
-
case ' ':
|
|
501
|
+
case ' ': chooseMonth(focusMonth); break;
|
|
449
502
|
case 'Escape': close(true); break;
|
|
450
503
|
default: handled = false;
|
|
451
504
|
}
|
|
@@ -539,14 +592,15 @@
|
|
|
539
592
|
function open() {
|
|
540
593
|
if (stateDisabled || stateReadonly) return;
|
|
541
594
|
if (!panel.hidden) return;
|
|
542
|
-
mode = 'days';
|
|
595
|
+
mode = monthOnly ? 'months' : 'days';
|
|
543
596
|
focusDate = selected || focusDate || new Date();
|
|
544
597
|
view = new Date(focusDate.getFullYear(), focusDate.getMonth(), 1);
|
|
598
|
+
if (monthOnly) focusMonth = view.getMonth();
|
|
545
599
|
var p = popCtl(); if (p) p.show(); else panel.hidden = false;
|
|
546
600
|
trigger.setAttribute('aria-expanded', 'true');
|
|
547
601
|
trigger.classList.add('is-open');
|
|
548
602
|
render();
|
|
549
|
-
focusFocusDate();
|
|
603
|
+
if (monthOnly) focusMonthCell(); else focusFocusDate();
|
|
550
604
|
document.addEventListener('click', onDocClick, true);
|
|
551
605
|
}
|
|
552
606
|
|
|
@@ -572,10 +626,10 @@
|
|
|
572
626
|
var api = {
|
|
573
627
|
wrap: wrap,
|
|
574
628
|
setState: setState,
|
|
575
|
-
getValue: function () { return selected ? toISO(selected) : ''; },
|
|
629
|
+
getValue: function () { return selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : ''; },
|
|
576
630
|
clear: function () { selected = null; updateDisplay(); if (!panel.hidden) render(); },
|
|
577
|
-
setMin: function (iso) { minDate =
|
|
578
|
-
setMax: function (iso) { maxDate =
|
|
631
|
+
setMin: function (iso) { minDate = parseVal(iso); if (!panel.hidden) render(); },
|
|
632
|
+
setMax: function (iso) { maxDate = parseVal(iso); if (!panel.hidden) render(); },
|
|
579
633
|
setRange: function (fromIso, toIso) {
|
|
580
634
|
rangeStart = parseISO(fromIso);
|
|
581
635
|
rangeEnd = parseISO(toIso);
|
package/dist/freeday-menu.js
CHANGED
|
@@ -36,7 +36,21 @@
|
|
|
36
36
|
its[(index + its.length) % its.length].focus();
|
|
37
37
|
}
|
|
38
38
|
var _pop = null;
|
|
39
|
-
|
|
39
|
+
/* `.fdy-menu--end` is the kit's existing spelling for "align this menu to the right", and the
|
|
40
|
+
stylesheet has always honoured it (left:auto;right:0). The popover path replaces that with
|
|
41
|
+
inline `fixed` coordinates, so without this line the class was silently dropped the moment
|
|
42
|
+
the menu went into the top layer: an account menu in a page gutter overshot its avatar and
|
|
43
|
+
ran to 4px off the screen edge. Reported from an app that patched it with a `translate`
|
|
44
|
+
hack keyed to the kit's own GAP. */
|
|
45
|
+
function popCtl() {
|
|
46
|
+
if (_pop === null && window.FreedayPopover) {
|
|
47
|
+
_pop = window.FreedayPopover.attach(menu, trigger, {
|
|
48
|
+
matchWidth: false,
|
|
49
|
+
align: menu.classList.contains('fdy-menu--end') ? 'end' : 'start'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return _pop;
|
|
53
|
+
}
|
|
40
54
|
function open(index) {
|
|
41
55
|
var p = popCtl(); if (p) p.show(); else menu.hidden = false;
|
|
42
56
|
trigger.setAttribute('aria-expanded', 'true');
|