@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
|
@@ -3,24 +3,56 @@ import { useEffect, type RefObject } from 'react';
|
|
|
3
3
|
// React port of adapters/vue/usePopover.ts. Lifts a dropdown panel into the top layer via the
|
|
4
4
|
// native Popover API so it escapes any ancestor overflow clip (a .fdy-card, a scroll container),
|
|
5
5
|
// then positions it `fixed` against its trigger (flip above when there's no room below; match the
|
|
6
|
-
// trigger width). Degrades to the panel's own
|
|
6
|
+
// trigger width; line either edge up with the trigger's). Degrades to the panel's own
|
|
7
|
+
// [hidden]/absolute CSS where Popover is unavailable.
|
|
7
8
|
|
|
8
9
|
interface PopoverElement extends HTMLElement {
|
|
9
10
|
showPopover(): void;
|
|
10
11
|
hidePopover(): void;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Which of the panel's edges lines up with the trigger's. `'start'` (the default everywhere it is
|
|
16
|
+
* omitted) puts the panel's LEFT edge on the trigger's left edge, right for an input-shaped
|
|
17
|
+
* dropdown. `'end'` puts its RIGHT edge on the trigger's right edge, which is what a panel much
|
|
18
|
+
* wider than its trigger needs when the trigger sits in a right-hand gutter or a table header.
|
|
19
|
+
* Same two names as the raw path's `place()`, so one concept does not grow a second vocabulary.
|
|
20
|
+
*/
|
|
21
|
+
export type FdyPopoverAlign = 'start' | 'end';
|
|
22
|
+
|
|
13
23
|
const GAP = 4; // ~ --space-1
|
|
14
24
|
const supported: boolean =
|
|
15
25
|
typeof HTMLElement !== 'undefined'
|
|
16
26
|
&& typeof (HTMLElement.prototype as Partial<PopoverElement>).showPopover === 'function';
|
|
17
27
|
|
|
18
|
-
function place(
|
|
19
|
-
|
|
28
|
+
function place(
|
|
29
|
+
panel: HTMLElement,
|
|
30
|
+
trigger: HTMLElement,
|
|
31
|
+
align: FdyPopoverAlign | undefined,
|
|
32
|
+
matchWidth: boolean | undefined,
|
|
33
|
+
): void {
|
|
34
|
+
/* Take the panel out of the flow BEFORE measuring anything. Until this runs the panel is still
|
|
35
|
+
* `position:absolute` with `top:calc(100% + …)` from its component's stylesheet, an author rule
|
|
36
|
+
* outranks the popover UA style, and in the top layer that `100%` resolves against the viewport.
|
|
37
|
+
* So a panel that has just been shown sits one viewport height down the page and drags the
|
|
38
|
+
* document's scrollable overflow with it. Where scrollbars take layout space (every Windows and
|
|
39
|
+
* Linux browser; macOS overlay scrollbars hide it) the document gains a classic scrollbar for the
|
|
40
|
+
* duration, documentElement.clientWidth drops by its width, and the page relays out that much
|
|
41
|
+
* narrower. A trigger rect read in that instant belongs to a layout the next three lines undo:
|
|
42
|
+
* measured 741 instead of 756 for a combo in a 24px gutter, a scrollbar's width to the left. */
|
|
20
43
|
panel.style.position = 'fixed';
|
|
21
44
|
panel.style.margin = '0';
|
|
22
45
|
panel.style.inset = 'auto';
|
|
23
|
-
|
|
46
|
+
const r: DOMRect = trigger.getBoundingClientRect();
|
|
47
|
+
/* The trigger's width as a FLOOR, and `matchWidth !== false` rather than a truthiness test, the
|
|
48
|
+
same spelling the raw `place()` uses. It is what keeps the four input-shaped panels looking
|
|
49
|
+
like one control with its list hanging off it, and it is not cosmetic: `.fdy-combo__listbox`,
|
|
50
|
+
`.fdy-autocomplete__listbox` and `.fdy-cascade__panel` are `min-width:100%`, and once the panel
|
|
51
|
+
is `fixed` in the top layer that 100% is the VIEWPORT, so without this line a 220px combo opens
|
|
52
|
+
a 1000px listbox. A panel whose stylesheet asks for a width of its own opts out instead:
|
|
53
|
+
`.fdy-filter` wants `min-width:14rem` and hangs off a 24px funnel, and an inline floor of 24px
|
|
54
|
+
outranks the rule, which left the typed filter panel 206px wide against the 224 it asked for. */
|
|
55
|
+
if (matchWidth !== false) panel.style.minWidth = `${r.width}px`;
|
|
24
56
|
const ph: number = panel.offsetHeight;
|
|
25
57
|
const pw: number = panel.offsetWidth;
|
|
26
58
|
const vw: number = document.documentElement.clientWidth;
|
|
@@ -28,16 +60,29 @@ function place(panel: HTMLElement, trigger: HTMLElement): void {
|
|
|
28
60
|
const below: number = vh - r.bottom - GAP;
|
|
29
61
|
const above: number = r.top - GAP;
|
|
30
62
|
const top: number = ph <= below || below >= above ? r.bottom + GAP : Math.max(GAP, r.top - GAP - ph);
|
|
31
|
-
let left: number = r.left;
|
|
32
|
-
|
|
63
|
+
let left: number = align === 'end' ? r.right - pw : r.left;
|
|
64
|
+
/* Clamped on BOTH sides, and that is not belt-and-braces: the first line keeps a start-aligned
|
|
65
|
+
panel on screen, the second keeps an end-aligned one on screen when the panel is wider than the
|
|
66
|
+
room to the left of its trigger. Keeping only one of them trades an overflow on one edge for an
|
|
67
|
+
overflow on the other. */
|
|
68
|
+
if (left + pw > vw - GAP) left = vw - GAP - pw;
|
|
69
|
+
if (left < GAP) left = GAP;
|
|
33
70
|
panel.style.top = `${Math.round(top)}px`;
|
|
34
71
|
panel.style.left = `${Math.round(left)}px`;
|
|
35
72
|
}
|
|
36
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Anchor a dropdown `panelRef` to `triggerRef` in the top layer while `open` is true. The panel
|
|
76
|
+
* element must carry `popover="manual"`. `align` and `matchWidth` are plain values, not an options
|
|
77
|
+
* object, so the effect below can list them in its dependencies without a caller having to
|
|
78
|
+
* memoise anything. Omitting them means `'start'` and `true`.
|
|
79
|
+
*/
|
|
37
80
|
export function usePopover(
|
|
38
81
|
panelRef: RefObject<HTMLElement | null>,
|
|
39
82
|
triggerRef: RefObject<HTMLElement | null>,
|
|
40
83
|
open: boolean,
|
|
84
|
+
align?: FdyPopoverAlign,
|
|
85
|
+
matchWidth?: boolean,
|
|
41
86
|
): void {
|
|
42
87
|
useEffect((): void | (() => void) => {
|
|
43
88
|
const panel: PopoverElement | null = panelRef.current as PopoverElement | null;
|
|
@@ -45,12 +90,12 @@ export function usePopover(
|
|
|
45
90
|
if (!supported || panel === null || trigger === null) return;
|
|
46
91
|
|
|
47
92
|
function reposition(): void {
|
|
48
|
-
if (panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger);
|
|
93
|
+
if (panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger, align, matchWidth);
|
|
49
94
|
}
|
|
50
95
|
|
|
51
96
|
if (open) {
|
|
52
97
|
if (!panel.matches(':popover-open')) panel.showPopover();
|
|
53
|
-
place(panel, trigger);
|
|
98
|
+
place(panel, trigger, align, matchWidth);
|
|
54
99
|
window.addEventListener('scroll', reposition, true);
|
|
55
100
|
window.addEventListener('resize', reposition);
|
|
56
101
|
return (): void => {
|
|
@@ -60,5 +105,5 @@ export function usePopover(
|
|
|
60
105
|
};
|
|
61
106
|
}
|
|
62
107
|
return;
|
|
63
|
-
}, [open, panelRef, triggerRef]);
|
|
108
|
+
}, [open, panelRef, triggerRef, align, matchWidth]);
|
|
64
109
|
}
|
|
@@ -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
|
|
@@ -12,6 +37,12 @@ import { usePopover } from '../usePopover';
|
|
|
12
37
|
// over the same kit classes. Open/close (outside-click + focusout) mirrors the
|
|
13
38
|
// already-approved FdyCombo.vue pattern. Range mode is out of scope (v1 = single date).
|
|
14
39
|
|
|
40
|
+
/** Which grid is on screen. The drill goes days -> months -> years and back down. */
|
|
41
|
+
type CalendarLevel = 'days' | 'months' | 'years';
|
|
42
|
+
|
|
43
|
+
/** What the picker answers with: a day (`YYYY-MM-DD`) or a whole month (`YYYY-MM`). */
|
|
44
|
+
type FdyDatepickerMode = 'day' | 'month';
|
|
45
|
+
|
|
15
46
|
interface DayCell {
|
|
16
47
|
date: Date;
|
|
17
48
|
iso: string;
|
|
@@ -25,7 +56,18 @@ interface DayCell {
|
|
|
25
56
|
}
|
|
26
57
|
|
|
27
58
|
const props = defineProps<{
|
|
59
|
+
/** ISO `YYYY-MM-DD`, or `YYYY-MM` when `mode` is `'month'`. `''`/`null` for empty. */
|
|
28
60
|
modelValue: string | null;
|
|
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
|
+
*/
|
|
70
|
+
mode?: FdyDatepickerMode;
|
|
29
71
|
min?: string;
|
|
30
72
|
max?: string;
|
|
31
73
|
locale?: string;
|
|
@@ -78,12 +120,6 @@ function parseISO(s: string | null | undefined): Date | null {
|
|
|
78
120
|
const d: Date = new Date(y, m - 1, day);
|
|
79
121
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
80
122
|
}
|
|
81
|
-
function startOfDay(d: Date): Date {
|
|
82
|
-
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
83
|
-
}
|
|
84
|
-
function startOfMonth(d: Date): Date {
|
|
85
|
-
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
86
|
-
}
|
|
87
123
|
function sameDay(a: Date | null, b: Date | null): boolean {
|
|
88
124
|
return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
89
125
|
}
|
|
@@ -92,11 +128,6 @@ function addDays(d: Date, n: number): Date {
|
|
|
92
128
|
x.setDate(x.getDate() + n);
|
|
93
129
|
return x;
|
|
94
130
|
}
|
|
95
|
-
function addMonths(d: Date, n: number): Date {
|
|
96
|
-
const x: Date = new Date(d);
|
|
97
|
-
x.setMonth(x.getMonth() + n);
|
|
98
|
-
return x;
|
|
99
|
-
}
|
|
100
131
|
|
|
101
132
|
const baseId: string = useId();
|
|
102
133
|
const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
|
|
@@ -113,13 +144,22 @@ const open: Ref<boolean> = ref(false);
|
|
|
113
144
|
// scroll container; positions against the trigger.
|
|
114
145
|
usePopover(panelEl, triggerEl, open);
|
|
115
146
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
147
|
+
const isMonthMode: ComputedRef<boolean> = computed((): boolean => props.mode === 'month');
|
|
148
|
+
/** Resolved once here so every formatter below reads the same tag. */
|
|
149
|
+
const activeLocale: ComputedRef<string> = computed((): string => props.locale ?? pageLocale());
|
|
150
|
+
/** Reading `props.mode` inside the computeds below is what keeps them in step with it. */
|
|
151
|
+
function parseValue(s: string | null | undefined): Date | null {
|
|
152
|
+
return props.mode === 'month' ? parseISOMonth(s) : parseISO(s);
|
|
153
|
+
}
|
|
154
|
+
const selectedDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.modelValue));
|
|
155
|
+
const minDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.min));
|
|
156
|
+
const maxDate: ComputedRef<Date | null> = computed((): Date | null => parseValue(props.max));
|
|
119
157
|
const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
|
|
120
158
|
const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
|
|
121
159
|
const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
|
|
122
|
-
const displayPlaceholder: ComputedRef<string> = computed(
|
|
160
|
+
const displayPlaceholder: ComputedRef<string> = computed(
|
|
161
|
+
(): string => props.placeholder ?? (isMonthMode.value ? 'Select month' : 'Select date'),
|
|
162
|
+
);
|
|
123
163
|
const showClear: ComputedRef<boolean> = computed(
|
|
124
164
|
(): boolean => props.clearable === true && selectedDate.value !== null && isDisabled.value === false && isReadonly.value === false,
|
|
125
165
|
);
|
|
@@ -145,119 +185,67 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
|
|
|
145
185
|
return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
|
|
146
186
|
});
|
|
147
187
|
|
|
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
|
-
|
|
188
|
+
/* Which grid is showing, and the calendar drills one level up instead of growing furniture beside
|
|
189
|
+
the title. Before this the only pointer route to another month was one click per month: August
|
|
190
|
+
2026 to March 2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all.
|
|
191
|
+
Named `level`, not `mode`: `mode` is the PROP that decides what the picker answers with, and one
|
|
192
|
+
name for both would read as the same thing in a template that mentions them together.
|
|
193
|
+
|
|
194
|
+
Month mode has no level below months, so the day grid is unreachable by construction rather
|
|
195
|
+
than by every path remembering not to ask for it. */
|
|
196
|
+
function startLevel(): CalendarLevel {
|
|
197
|
+
return props.mode === 'month' ? 'months' : 'days';
|
|
198
|
+
}
|
|
199
|
+
const level: Ref<CalendarLevel> = ref(startLevel());
|
|
152
200
|
const focusMonth: Ref<number> = ref(0);
|
|
153
201
|
const focusYear: Ref<number> = ref(0);
|
|
154
202
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
different spot every time you stepped. */
|
|
158
|
-
const YEARS_PER_PAGE = 12;
|
|
159
|
-
const yearPageStart = (year: number): number => Math.floor(year / YEARS_PER_PAGE) * YEARS_PER_PAGE;
|
|
160
|
-
|
|
161
|
-
const monthCellFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
162
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'short' }),
|
|
163
|
-
);
|
|
164
|
-
const monthNameFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
165
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
|
|
203
|
+
const formatters: ComputedRef<FdyMonthFormatters> = computed(
|
|
204
|
+
(): FdyMonthFormatters => monthFormatters(activeLocale.value),
|
|
166
205
|
);
|
|
167
206
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** A month is only unreachable when the WHOLE month falls outside min/max. Checked against the
|
|
179
|
-
* bounds directly, not by testing both ends with isDayDisabled: a range that sits INSIDE one month
|
|
180
|
-
* disables both ends while the middle is perfectly selectable. */
|
|
181
|
-
function isMonthDisabled(year: number, month: number): boolean {
|
|
182
|
-
const first: Date = new Date(year, month, 1);
|
|
183
|
-
const last: Date = new Date(year, month + 1, 0);
|
|
184
|
-
if (minDate.value !== null && startOfDay(last).getTime() < startOfDay(minDate.value).getTime()) return true;
|
|
185
|
-
if (maxDate.value !== null && startOfDay(first).getTime() > startOfDay(maxDate.value).getTime()) return true;
|
|
186
|
-
return false;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const monthCells: ComputedRef<MonthCell[]> = computed((): MonthCell[] => {
|
|
190
|
-
const year: number = view.value.getFullYear();
|
|
191
|
-
const today: Date = new Date();
|
|
192
|
-
const sel: Date | null = selectedDate.value;
|
|
193
|
-
return Array.from({ length: 12 }, (_unused: unknown, index: number): MonthCell => {
|
|
194
|
-
const cellDate: Date = new Date(year, index, 1);
|
|
195
|
-
return {
|
|
196
|
-
index,
|
|
197
|
-
label: monthCellFmt.value.format(cellDate),
|
|
198
|
-
ariaLabel: monthNameFmt.value.format(cellDate),
|
|
199
|
-
selected: sel !== null && sel.getFullYear() === year && sel.getMonth() === index,
|
|
200
|
-
today: today.getFullYear() === year && today.getMonth() === index,
|
|
201
|
-
disabled: isMonthDisabled(year, index),
|
|
202
|
-
focusable: index === focusMonth.value,
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
interface YearCell {
|
|
208
|
-
year: number;
|
|
209
|
-
selected: boolean;
|
|
210
|
-
today: boolean;
|
|
211
|
-
disabled: boolean;
|
|
212
|
-
focusable: boolean;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** A year is unreachable only when EVERY month in it falls outside min/max, the same rule
|
|
216
|
-
* isMonthDisabled applies one level down, and for the same reason. */
|
|
217
|
-
function isYearDisabled(year: number): boolean {
|
|
218
|
-
if (minDate.value !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate.value).getTime()) return true;
|
|
219
|
-
if (maxDate.value !== null && startOfDay(new Date(year, 0, 1)).getTime() > startOfDay(maxDate.value).getTime()) return true;
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
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
|
+
}));
|
|
222
215
|
|
|
223
216
|
const yearPage: ComputedRef<number> = computed((): number => yearPageStart(focusYear.value));
|
|
224
217
|
|
|
225
|
-
const yearCells: ComputedRef<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
year,
|
|
233
|
-
selected: sel !== null && sel.getFullYear() === year,
|
|
234
|
-
today: thisYear === year,
|
|
235
|
-
disabled: isYearDisabled(year),
|
|
236
|
-
focusable: year === focusYear.value,
|
|
237
|
-
};
|
|
238
|
-
});
|
|
239
|
-
});
|
|
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
|
+
}));
|
|
240
225
|
|
|
241
|
-
const
|
|
242
|
-
(): string => `${yearPage.value} – ${yearPage.value + YEARS_PER_PAGE - 1}`,
|
|
243
|
-
);
|
|
226
|
+
const yearRangeText: ComputedRef<string> = computed((): string => yearRangeLabel(yearPage.value));
|
|
244
227
|
|
|
245
228
|
const monthFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
246
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
229
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { month: 'long', year: 'numeric' }),
|
|
247
230
|
);
|
|
248
231
|
const valueFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
249
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
232
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { day: '2-digit', month: 'short', year: 'numeric' }),
|
|
250
233
|
);
|
|
251
234
|
const dayLabelFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
252
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
235
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
|
|
253
236
|
);
|
|
254
237
|
const dowFmt: ComputedRef<Intl.DateTimeFormat> = computed(
|
|
255
|
-
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(
|
|
238
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(activeLocale.value, { weekday: 'short' }),
|
|
256
239
|
);
|
|
257
240
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
241
|
+
/* Month mode shows the month through the SAME formatter the month cells and the day grid's title
|
|
242
|
+
already use, which is the whole point of the mode: <input type="month"> names the month in the
|
|
243
|
+
browser's language, this names it in the page's. */
|
|
244
|
+
const displayValue: ComputedRef<string> = computed((): string => {
|
|
245
|
+
if (selectedDate.value === null) return displayPlaceholder.value;
|
|
246
|
+
const fmt: Intl.DateTimeFormat = isMonthMode.value ? formatters.value.name : valueFmt.value;
|
|
247
|
+
return fmt.format(selectedDate.value);
|
|
248
|
+
});
|
|
261
249
|
|
|
262
250
|
const weekdayHeaders: ComputedRef<string[]> = computed((): string[] => {
|
|
263
251
|
const monday: Date = new Date(2024, 0, 1); // a known Monday
|
|
@@ -310,7 +298,7 @@ function moveFocus(next: Date): void {
|
|
|
310
298
|
|
|
311
299
|
function openMonthGrid(): void {
|
|
312
300
|
focusMonth.value = view.value.getMonth();
|
|
313
|
-
|
|
301
|
+
level.value = 'months';
|
|
314
302
|
void nextTick((): void => focusMonthCell());
|
|
315
303
|
}
|
|
316
304
|
|
|
@@ -319,6 +307,25 @@ function focusMonthCell(): void {
|
|
|
319
307
|
cell?.focus();
|
|
320
308
|
}
|
|
321
309
|
|
|
310
|
+
/* What a month cell DOES, decided in one place for the click handler and the Enter key alike. In
|
|
311
|
+
day mode a month is a step on the way down; in month mode it is the answer. */
|
|
312
|
+
function chooseMonth(index: number): void {
|
|
313
|
+
if (isMonthMode.value) pickMonth(index);
|
|
314
|
+
else openMonth(index);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pickMonth(index: number): void {
|
|
318
|
+
const year: number = view.value.getFullYear();
|
|
319
|
+
if (monthDisabled(year, index, minDate.value, maxDate.value)) return;
|
|
320
|
+
const first: Date = new Date(year, index, 1);
|
|
321
|
+
focusMonth.value = index;
|
|
322
|
+
focusDate.value = first;
|
|
323
|
+
const iso: string = toISOMonth(first);
|
|
324
|
+
emit('update:modelValue', iso);
|
|
325
|
+
emit('change', iso);
|
|
326
|
+
closePanel(true);
|
|
327
|
+
}
|
|
328
|
+
|
|
322
329
|
/* Picking a month is NAVIGATION, not selection: it drops back to the day grid with the roving cell
|
|
323
330
|
clamped into the new month, so nothing is committed until a day is chosen. */
|
|
324
331
|
function openMonth(index: number): void {
|
|
@@ -327,13 +334,13 @@ function openMonth(index: number): void {
|
|
|
327
334
|
const day: number = Math.min(focusDate.value.getDate(), daysInMonth);
|
|
328
335
|
view.value = new Date(year, index, 1);
|
|
329
336
|
focusDate.value = new Date(year, index, day);
|
|
330
|
-
|
|
337
|
+
level.value = 'days';
|
|
331
338
|
void nextTick((): void => focusCell());
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
function openYearGrid(): void {
|
|
335
342
|
focusYear.value = view.value.getFullYear();
|
|
336
|
-
|
|
343
|
+
level.value = 'years';
|
|
337
344
|
void nextTick((): void => focusYearCell());
|
|
338
345
|
}
|
|
339
346
|
|
|
@@ -347,7 +354,7 @@ function focusYearCell(): void {
|
|
|
347
354
|
function openYear(year: number): void {
|
|
348
355
|
view.value = new Date(year, view.value.getMonth(), 1);
|
|
349
356
|
focusMonth.value = view.value.getMonth();
|
|
350
|
-
|
|
357
|
+
level.value = 'months';
|
|
351
358
|
void nextTick((): void => focusMonthCell());
|
|
352
359
|
}
|
|
353
360
|
|
|
@@ -358,47 +365,29 @@ function moveYearFocus(year: number): void {
|
|
|
358
365
|
}
|
|
359
366
|
|
|
360
367
|
function onYearKeydown(e: KeyboardEvent): void {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
case 'End': moveYearFocus(yearPageStart(focusYear.value) + YEARS_PER_PAGE - 1); break;
|
|
369
|
-
case 'PageUp': moveYearFocus(focusYear.value - YEARS_PER_PAGE); break;
|
|
370
|
-
case 'PageDown': moveYearFocus(focusYear.value + YEARS_PER_PAGE); break;
|
|
371
|
-
case 'Enter':
|
|
372
|
-
case ' ': openYear(focusYear.value); break;
|
|
373
|
-
case 'Escape': closePanel(true); break;
|
|
374
|
-
default: handled = false;
|
|
375
|
-
}
|
|
376
|
-
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);
|
|
377
375
|
}
|
|
378
376
|
|
|
377
|
+
/** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
|
|
379
378
|
function moveMonthFocus(index: number, yearDelta: number): void {
|
|
380
379
|
if (yearDelta !== 0) view.value = addMonths(view.value, yearDelta * 12);
|
|
381
|
-
focusMonth.value =
|
|
380
|
+
focusMonth.value = index;
|
|
382
381
|
void nextTick((): void => focusMonthCell());
|
|
383
382
|
}
|
|
384
383
|
|
|
385
384
|
function onMonthKeydown(e: KeyboardEvent): void {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
case 'Home': moveMonthFocus(0, 0); break;
|
|
393
|
-
case 'End': moveMonthFocus(11, 0); break;
|
|
394
|
-
case 'PageUp': moveMonthFocus(focusMonth.value, -1); break;
|
|
395
|
-
case 'PageDown': moveMonthFocus(focusMonth.value, 1); break;
|
|
396
|
-
case 'Enter':
|
|
397
|
-
case ' ': openMonth(focusMonth.value); break;
|
|
398
|
-
case 'Escape': closePanel(true); break;
|
|
399
|
-
default: handled = false;
|
|
400
|
-
}
|
|
401
|
-
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);
|
|
402
391
|
}
|
|
403
392
|
|
|
404
393
|
function pick(d: Date): void {
|
|
@@ -423,7 +412,16 @@ function openPanel(): void {
|
|
|
423
412
|
if (isDisabled.value || isReadonly.value || open.value) return;
|
|
424
413
|
focusDate.value = selectedDate.value ?? focusDate.value ?? new Date();
|
|
425
414
|
view.value = new Date(focusDate.value.getFullYear(), focusDate.value.getMonth(), 1);
|
|
415
|
+
/* Reopening starts at the mode's own floor. Without this a month picker that drilled to the year
|
|
416
|
+
grid and was dismissed would open on years the next time, and a keyboard user would land on a
|
|
417
|
+
grid that cannot answer. */
|
|
418
|
+
level.value = startLevel();
|
|
426
419
|
open.value = true;
|
|
420
|
+
if (isMonthMode.value) {
|
|
421
|
+
focusMonth.value = view.value.getMonth();
|
|
422
|
+
void nextTick((): void => focusMonthCell());
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
427
425
|
void nextTick((): void => focusCell());
|
|
428
426
|
}
|
|
429
427
|
|
|
@@ -563,11 +561,11 @@ onBeforeUnmount((): void => {
|
|
|
563
561
|
<button
|
|
564
562
|
type="button"
|
|
565
563
|
class="fdy-cal__nav"
|
|
566
|
-
:aria-label="
|
|
564
|
+
:aria-label="level === 'years' ? prevYearsLabelText : level === 'months' ? prevYearLabelText : prevMonthLabelText"
|
|
567
565
|
@click="
|
|
568
|
-
|
|
569
|
-
? moveYearFocus(focusYear -
|
|
570
|
-
:
|
|
566
|
+
level === 'years'
|
|
567
|
+
? moveYearFocus(focusYear - YEARS_PER_PAGE)
|
|
568
|
+
: level === 'months'
|
|
571
569
|
? moveMonthFocus(focusMonth, -1)
|
|
572
570
|
: (view = addMonths(view, -1))
|
|
573
571
|
"
|
|
@@ -576,23 +574,23 @@ onBeforeUnmount((): void => {
|
|
|
576
574
|
:id="titleId"
|
|
577
575
|
type="button"
|
|
578
576
|
class="fdy-cal__title"
|
|
579
|
-
:aria-label="
|
|
580
|
-
@click="
|
|
581
|
-
>{{
|
|
577
|
+
:aria-label="level === 'years' ? undefined : level === 'months' ? chooseYearLabelText : chooseMonthLabelText"
|
|
578
|
+
@click="level === 'months' ? openYearGrid() : openMonthGrid()"
|
|
579
|
+
>{{ level === 'years' ? yearRangeText : level === 'months' ? String(view.getFullYear()) : monthFmt.format(view) }}</button>
|
|
582
580
|
<button
|
|
583
581
|
type="button"
|
|
584
582
|
class="fdy-cal__nav"
|
|
585
|
-
:aria-label="
|
|
583
|
+
:aria-label="level === 'years' ? nextYearsLabelText : level === 'months' ? nextYearLabelText : nextMonthLabelText"
|
|
586
584
|
@click="
|
|
587
|
-
|
|
588
|
-
? moveYearFocus(focusYear +
|
|
589
|
-
:
|
|
585
|
+
level === 'years'
|
|
586
|
+
? moveYearFocus(focusYear + YEARS_PER_PAGE)
|
|
587
|
+
: level === 'months'
|
|
590
588
|
? moveMonthFocus(focusMonth, 1)
|
|
591
589
|
: (view = addMonths(view, 1))
|
|
592
590
|
"
|
|
593
591
|
>›</button>
|
|
594
592
|
</div>
|
|
595
|
-
<div v-if="
|
|
593
|
+
<div v-if="level === 'years'" class="fdy-cal__grid fdy-cal__grid--years" role="grid" :aria-labelledby="titleId" @keydown="onYearKeydown">
|
|
596
594
|
<button
|
|
597
595
|
v-for="cell in yearCells"
|
|
598
596
|
:key="cell.year"
|
|
@@ -607,7 +605,7 @@ onBeforeUnmount((): void => {
|
|
|
607
605
|
@click="openYear(cell.year)"
|
|
608
606
|
>{{ cell.year }}</button>
|
|
609
607
|
</div>
|
|
610
|
-
<div v-else-if="
|
|
608
|
+
<div v-else-if="level === 'months'" class="fdy-cal__grid fdy-cal__grid--months" role="grid" :aria-labelledby="titleId" @keydown="onMonthKeydown">
|
|
611
609
|
<button
|
|
612
610
|
v-for="cell in monthCells"
|
|
613
611
|
:key="cell.index"
|
|
@@ -619,7 +617,7 @@ onBeforeUnmount((): void => {
|
|
|
619
617
|
:aria-selected="cell.selected ? 'true' : undefined"
|
|
620
618
|
:disabled="cell.disabled"
|
|
621
619
|
:tabindex="cell.focusable ? 0 : -1"
|
|
622
|
-
@click="
|
|
620
|
+
@click="chooseMonth(cell.index)"
|
|
623
621
|
>{{ cell.label }}</button>
|
|
624
622
|
</div>
|
|
625
623
|
<div v-else class="fdy-cal__grid" role="grid" :aria-labelledby="titleId" @keydown="onGridKeydown">
|