@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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { usePopover } from '../usePopover';
|
|
4
|
+
import {
|
|
5
|
+
YEARS_PER_PAGE,
|
|
6
|
+
addMonths,
|
|
7
|
+
monthCells as buildMonthCells,
|
|
8
|
+
monthFormatters,
|
|
9
|
+
monthKeyIntent,
|
|
10
|
+
pageLocale,
|
|
11
|
+
parseISOMonth,
|
|
12
|
+
startOfMonth,
|
|
13
|
+
toISOMonth,
|
|
14
|
+
yearCells as buildYearCells,
|
|
15
|
+
yearKeyIntent,
|
|
16
|
+
yearPageStart,
|
|
17
|
+
yearRangeLabel,
|
|
18
|
+
type FdyMonthCell,
|
|
19
|
+
type FdyMonthFormatters,
|
|
20
|
+
type FdyYearCell,
|
|
21
|
+
} from '../../core/month-grid.js';
|
|
22
|
+
|
|
23
|
+
// React port of adapters/vue/components/FdyMonthpicker.vue over freeday's `.fdy-datepicker` /
|
|
24
|
+
// `.fdy-cal__*` classes (see src/components/datepicker.css). Controlled (`value` + `onChange` in
|
|
25
|
+
// place of Vue's `v-model`). The month and year grids come from adapters/core/month-grid.js, the
|
|
26
|
+
// same module FdyDatepicker reads, so the rule has one home.
|
|
27
|
+
//
|
|
28
|
+
// It exists for its SIGNATURE. 3.8.0 answered "we need a month" with `mode="month"` on
|
|
29
|
+
// FdyDatepicker, which left `value: string | null` carrying `YYYY-MM-DD` or `YYYY-MM` depending on
|
|
30
|
+
// a different prop. Here the value has one format, there is no mode to read, and the day grid is
|
|
31
|
+
// absent rather than merely unvisited.
|
|
32
|
+
|
|
33
|
+
/** The one format this component speaks, re-exported so the name has a single home. */
|
|
34
|
+
export type { FdyMonth } from '../../core/month-grid';
|
|
35
|
+
|
|
36
|
+
/** Which grid is on screen. There is no level below months here: that is the component. */
|
|
37
|
+
type CalendarLevel = 'months' | 'years';
|
|
38
|
+
|
|
39
|
+
export interface FdyMonthpickerProps {
|
|
40
|
+
/** The chosen month, ISO `YYYY-MM` (`'2026-10'`). `''`/`null` for empty. One format, always. */
|
|
41
|
+
value: string | null;
|
|
42
|
+
onChange: (value: string) => void;
|
|
43
|
+
/** Earliest selectable month, `YYYY-MM` (a full `YYYY-MM-DD` is read too, as its own month). */
|
|
44
|
+
min?: string;
|
|
45
|
+
/** Latest selectable month. A bound that falls inside a month leaves that month pickable. */
|
|
46
|
+
max?: string;
|
|
47
|
+
/** BCP-47 tag for the month names. Defaults to the page's `<html lang>`, then `en`. */
|
|
48
|
+
locale?: string;
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
/** Locked/view mode: stays focusable and shows its month, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
|
|
52
|
+
readonly?: boolean;
|
|
53
|
+
invalid?: boolean;
|
|
54
|
+
describedby?: string;
|
|
55
|
+
id?: string;
|
|
56
|
+
ariaLabelledby?: string;
|
|
57
|
+
/** Show a clear (×) button in the trigger when a month is set, so an optional month can be unset. Calls onChange('') to reset. Off by default. */
|
|
58
|
+
clearable?: boolean;
|
|
59
|
+
/** aria-label for the clear button (when `clearable`). Default 'Clear month'. */
|
|
60
|
+
clearLabel?: string;
|
|
61
|
+
/** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
|
|
62
|
+
prevYearLabel?: string;
|
|
63
|
+
nextYearLabel?: string;
|
|
64
|
+
/** aria-label for the title button that drills from the month grid to the year grid. Default 'Choose year'. */
|
|
65
|
+
chooseYearLabel?: string;
|
|
66
|
+
/** aria-labels for the page arrows shown in the year grid. Defaults 'Previous years' / 'Next years'. */
|
|
67
|
+
prevYearsLabel?: string;
|
|
68
|
+
nextYearsLabel?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function FdyMonthpicker(props: FdyMonthpickerProps): JSX.Element {
|
|
72
|
+
const baseId: string = useId();
|
|
73
|
+
const triggerId: string = props.id ?? `${baseId}-trigger`;
|
|
74
|
+
const titleId: string = `${baseId}-title`;
|
|
75
|
+
|
|
76
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
78
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const monthRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
80
|
+
const yearRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
81
|
+
// Set by the move/open handlers, consumed by the focus-sync effects below (React's analogue of
|
|
82
|
+
// Vue's `nextTick(focusCell)`).
|
|
83
|
+
const pendingMonthFocusRef = useRef<number | null>(null);
|
|
84
|
+
const pendingYearFocusRef = useRef<number | null>(null);
|
|
85
|
+
|
|
86
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
87
|
+
const [level, setLevel] = useState<CalendarLevel>('months');
|
|
88
|
+
const [focusMonth, setFocusMonth] = useState<number>(0);
|
|
89
|
+
const [focusYear, setFocusYear] = useState<number>(0);
|
|
90
|
+
/** The year the month grid shows, held as its January so the shared helpers can take a Date. */
|
|
91
|
+
const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseISOMonth(props.value) ?? new Date()));
|
|
92
|
+
|
|
93
|
+
// Render the panel in the top layer (popover) so it never gets clipped by a card or scroll
|
|
94
|
+
// container; positions against the trigger.
|
|
95
|
+
usePopover(panelRef, triggerRef, open);
|
|
96
|
+
|
|
97
|
+
// Popover attr for React 18 (JSX may not type it): set once on mount.
|
|
98
|
+
useEffect((): void => {
|
|
99
|
+
panelRef.current?.setAttribute('popover', 'manual');
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
/** Resolved once so every formatter below reads the same tag. */
|
|
103
|
+
const activeLocale: string = props.locale ?? pageLocale();
|
|
104
|
+
const formatters: FdyMonthFormatters = useMemo(
|
|
105
|
+
(): FdyMonthFormatters => monthFormatters(activeLocale),
|
|
106
|
+
[activeLocale],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const selectedDate: Date | null = useMemo((): Date | null => parseISOMonth(props.value), [props.value]);
|
|
110
|
+
const minDate: Date | null = useMemo((): Date | null => parseISOMonth(props.min), [props.min]);
|
|
111
|
+
const maxDate: Date | null = useMemo((): Date | null => parseISOMonth(props.max), [props.max]);
|
|
112
|
+
const isDisabled: boolean = props.disabled === true;
|
|
113
|
+
const isReadonly: boolean = props.readonly === true;
|
|
114
|
+
const isInvalid: boolean = props.invalid === true;
|
|
115
|
+
const displayPlaceholder: string = props.placeholder ?? 'Select month';
|
|
116
|
+
const showClear: boolean = props.clearable === true && selectedDate !== null && !isDisabled && !isReadonly;
|
|
117
|
+
const clearLabelText: string = props.clearLabel ?? 'Clear month';
|
|
118
|
+
const chooseYearLabelText: string = props.chooseYearLabel ?? 'Choose year';
|
|
119
|
+
const prevYearLabelText: string = props.prevYearLabel ?? 'Previous year';
|
|
120
|
+
const nextYearLabelText: string = props.nextYearLabel ?? 'Next year';
|
|
121
|
+
const prevYearsLabelText: string = props.prevYearsLabel ?? 'Previous years';
|
|
122
|
+
const nextYearsLabelText: string = props.nextYearsLabel ?? 'Next years';
|
|
123
|
+
|
|
124
|
+
/* The month named in the page's language, through the same formatter the cells use. This is the
|
|
125
|
+
capability `<input type="month">` cannot be pushed into: it reads the browser. */
|
|
126
|
+
const displayValue: string = selectedDate === null ? displayPlaceholder : formatters.name.format(selectedDate);
|
|
127
|
+
|
|
128
|
+
const monthCells: FdyMonthCell[] = useMemo((): FdyMonthCell[] => buildMonthCells({
|
|
129
|
+
year: viewMonth.getFullYear(),
|
|
130
|
+
selected: selectedDate,
|
|
131
|
+
min: minDate,
|
|
132
|
+
max: maxDate,
|
|
133
|
+
focusMonth,
|
|
134
|
+
formatters,
|
|
135
|
+
}), [viewMonth, selectedDate, minDate, maxDate, focusMonth, formatters]);
|
|
136
|
+
|
|
137
|
+
const yearPage: number = yearPageStart(focusYear);
|
|
138
|
+
|
|
139
|
+
const yearCells: FdyYearCell[] = useMemo((): FdyYearCell[] => buildYearCells({
|
|
140
|
+
pageStart: yearPage,
|
|
141
|
+
selected: selectedDate,
|
|
142
|
+
min: minDate,
|
|
143
|
+
max: maxDate,
|
|
144
|
+
focusYear,
|
|
145
|
+
}), [yearPage, selectedDate, minDate, maxDate, focusYear]);
|
|
146
|
+
|
|
147
|
+
useEffect((): void => {
|
|
148
|
+
if (pendingMonthFocusRef.current === null) return;
|
|
149
|
+
const index: number = pendingMonthFocusRef.current;
|
|
150
|
+
pendingMonthFocusRef.current = null;
|
|
151
|
+
monthRefs.current[index]?.focus();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
useEffect((): void => {
|
|
155
|
+
if (pendingYearFocusRef.current === null) return;
|
|
156
|
+
const year: number = pendingYearFocusRef.current;
|
|
157
|
+
pendingYearFocusRef.current = null;
|
|
158
|
+
yearRefs.current[year]?.focus();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** `index` is already 0-11: the wrap lives in `monthKeyIntent`, so it is applied in one place. */
|
|
162
|
+
function moveMonthFocus(index: number, yearDelta: number): void {
|
|
163
|
+
if (yearDelta !== 0) setViewMonth(addMonths(viewMonth, yearDelta * 12));
|
|
164
|
+
setFocusMonth(index);
|
|
165
|
+
pendingMonthFocusRef.current = index;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* A month is the ANSWER here. In FdyDatepicker the same click walks down into the month's days,
|
|
169
|
+
which is the one thing the two components do differently with the shared grid. */
|
|
170
|
+
function pickMonth(index: number): void {
|
|
171
|
+
if (monthCells[index].disabled) return;
|
|
172
|
+
const first: Date = new Date(viewMonth.getFullYear(), index, 1);
|
|
173
|
+
setFocusMonth(index);
|
|
174
|
+
setViewMonth(first);
|
|
175
|
+
props.onChange(toISOMonth(first));
|
|
176
|
+
closePanel(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function openYearGrid(): void {
|
|
180
|
+
setFocusYear(viewMonth.getFullYear());
|
|
181
|
+
setLevel('years');
|
|
182
|
+
pendingYearFocusRef.current = viewMonth.getFullYear();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function openMonthGridAt(year: number, month: number): void {
|
|
186
|
+
setViewMonth(new Date(year, month, 1));
|
|
187
|
+
setFocusMonth(month);
|
|
188
|
+
setLevel('months');
|
|
189
|
+
pendingMonthFocusRef.current = month;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Picking a year is NAVIGATION and commits nothing: it drops to that year's months, where the
|
|
193
|
+
answer is given. Without this level, reaching a month four years back is four arrow presses per
|
|
194
|
+
year and no pointer route at all. */
|
|
195
|
+
function openYear(year: number): void {
|
|
196
|
+
openMonthGridAt(year, viewMonth.getMonth());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function moveYearFocus(year: number): void {
|
|
200
|
+
setFocusYear(year);
|
|
201
|
+
setViewMonth(new Date(year, viewMonth.getMonth(), 1));
|
|
202
|
+
pendingYearFocusRef.current = year;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onMonthKeydown(e: React.KeyboardEvent): void {
|
|
206
|
+
const intent = monthKeyIntent(e.key, focusMonth);
|
|
207
|
+
if (intent.action === 'none') return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
if (intent.action === 'focus') moveMonthFocus(intent.index, intent.yearDelta);
|
|
210
|
+
else if (intent.action === 'choose') pickMonth(intent.index);
|
|
211
|
+
else closePanel(true);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function onYearKeydown(e: React.KeyboardEvent): void {
|
|
215
|
+
const intent = yearKeyIntent(e.key, focusYear);
|
|
216
|
+
if (intent.action === 'none') return;
|
|
217
|
+
/* stopPropagation as well, matching FdyDatepicker's year grid: the panel is in the top layer
|
|
218
|
+
and a bubbling Escape would otherwise be read a second time by whatever is behind it. */
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
if (intent.action === 'focus') moveYearFocus(intent.year);
|
|
222
|
+
else if (intent.action === 'choose') openYear(intent.year);
|
|
223
|
+
else closePanel(true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Clear (reset to empty). Calls onChange(''), parseISOMonth('') is null, so the placeholder shows.
|
|
227
|
+
function clearValue(): void {
|
|
228
|
+
props.onChange('');
|
|
229
|
+
setOpen(false);
|
|
230
|
+
triggerRef.current?.focus();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function openPanel(): void {
|
|
234
|
+
if (isDisabled || isReadonly || open) return;
|
|
235
|
+
const next: Date = startOfMonth(selectedDate ?? viewMonth);
|
|
236
|
+
setViewMonth(next);
|
|
237
|
+
/* Reopening starts on months. Without this a picker that drilled to the year grid and was
|
|
238
|
+
dismissed would open on years next time, and a keyboard user would land on a grid that
|
|
239
|
+
cannot answer. */
|
|
240
|
+
setLevel('months');
|
|
241
|
+
setFocusMonth(next.getMonth());
|
|
242
|
+
setOpen(true);
|
|
243
|
+
pendingMonthFocusRef.current = next.getMonth();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function closePanel(returnFocus: boolean): void {
|
|
247
|
+
if (!open) return;
|
|
248
|
+
setOpen(false);
|
|
249
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toggle(): void {
|
|
253
|
+
if (open) closePanel(false);
|
|
254
|
+
else openPanel();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* A null relatedTarget means focus fell to <body>, which is what happens when the element the
|
|
258
|
+
user just pressed is REMOVED by the click it triggered (drilling into the year grid replaces
|
|
259
|
+
the grid, and with it the cell that had focus). That is not focus leaving the control, and
|
|
260
|
+
closing on it made the panel vanish mid-navigation. A pointer that really lands outside is
|
|
261
|
+
handled by the mousedown listener, which is the reliable path for that case. */
|
|
262
|
+
const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
|
|
263
|
+
const next: EventTarget | null = e.relatedTarget;
|
|
264
|
+
if (next === null) return;
|
|
265
|
+
if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closePanel(false);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Close when a pointer lands outside the whole control.
|
|
269
|
+
useEffect((): void | (() => void) => {
|
|
270
|
+
if (!open) return;
|
|
271
|
+
const onDocPointerDown = (e: MouseEvent): void => {
|
|
272
|
+
const t: EventTarget | null = e.target;
|
|
273
|
+
if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closePanel(false);
|
|
274
|
+
};
|
|
275
|
+
document.addEventListener('mousedown', onDocPointerDown);
|
|
276
|
+
return (): void => document.removeEventListener('mousedown', onDocPointerDown);
|
|
277
|
+
}, [open]);
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'} onBlur={onFocusout}>
|
|
281
|
+
<button
|
|
282
|
+
id={triggerId}
|
|
283
|
+
ref={triggerRef}
|
|
284
|
+
type="button"
|
|
285
|
+
className={open ? 'fdy-datepicker__trigger is-open' : 'fdy-datepicker__trigger'}
|
|
286
|
+
aria-haspopup="dialog"
|
|
287
|
+
aria-expanded={open}
|
|
288
|
+
aria-labelledby={props.ariaLabelledby}
|
|
289
|
+
aria-invalid={isInvalid ? 'true' : undefined}
|
|
290
|
+
aria-readonly={isReadonly ? 'true' : undefined}
|
|
291
|
+
aria-describedby={props.describedby}
|
|
292
|
+
disabled={isDisabled}
|
|
293
|
+
onClick={toggle}
|
|
294
|
+
>
|
|
295
|
+
<span className={selectedDate === null ? 'fdy-datepicker__value fdy-datepicker__value--placeholder' : 'fdy-datepicker__value'}>
|
|
296
|
+
{displayValue}
|
|
297
|
+
</span>
|
|
298
|
+
<span className={showClear ? 'fdy-datepicker__icon is-hidden' : 'fdy-datepicker__icon'} aria-hidden="true">
|
|
299
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
300
|
+
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
|
|
301
|
+
<path d="M16 2v4M8 2v4M3 10h18"></path>
|
|
302
|
+
</svg>
|
|
303
|
+
</span>
|
|
304
|
+
</button>
|
|
305
|
+
{showClear ? (
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
className="fdy-datepicker__clear"
|
|
309
|
+
aria-label={clearLabelText}
|
|
310
|
+
onMouseDown={(e: React.MouseEvent): void => e.preventDefault()}
|
|
311
|
+
onClick={clearValue}
|
|
312
|
+
>
|
|
313
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
314
|
+
<path d="M18 6 6 18M6 6l12 12"></path>
|
|
315
|
+
</svg>
|
|
316
|
+
</button>
|
|
317
|
+
) : null}
|
|
318
|
+
|
|
319
|
+
<div ref={panelRef} className="fdy-datepicker__panel" role="dialog" aria-modal="false" aria-labelledby={titleId} hidden={!open}>
|
|
320
|
+
{/* ONE head for both levels on purpose: swapping it destroys the very button the user just
|
|
321
|
+
pressed, focus falls to <body>, and the panel's own focusout handler closes it. */}
|
|
322
|
+
<div className="fdy-cal__head">
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
className="fdy-cal__nav"
|
|
326
|
+
aria-label={level === 'years' ? prevYearsLabelText : prevYearLabelText}
|
|
327
|
+
onClick={(): void => {
|
|
328
|
+
if (level === 'years') moveYearFocus(focusYear - YEARS_PER_PAGE);
|
|
329
|
+
else moveMonthFocus(focusMonth, -1);
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
‹
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
id={titleId}
|
|
336
|
+
type="button"
|
|
337
|
+
className="fdy-cal__title"
|
|
338
|
+
aria-label={level === 'years' ? undefined : chooseYearLabelText}
|
|
339
|
+
onClick={(): void => {
|
|
340
|
+
if (level === 'years') openMonthGridAt(viewMonth.getFullYear(), viewMonth.getMonth());
|
|
341
|
+
else openYearGrid();
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
{level === 'years' ? yearRangeLabel(yearPage) : String(viewMonth.getFullYear())}
|
|
345
|
+
</button>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="fdy-cal__nav"
|
|
349
|
+
aria-label={level === 'years' ? nextYearsLabelText : nextYearLabelText}
|
|
350
|
+
onClick={(): void => {
|
|
351
|
+
if (level === 'years') moveYearFocus(focusYear + YEARS_PER_PAGE);
|
|
352
|
+
else moveMonthFocus(focusMonth, 1);
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
›
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
{level === 'years' ? (
|
|
359
|
+
<div className="fdy-cal__grid fdy-cal__grid--years" role="grid" aria-labelledby={titleId} onKeyDown={onYearKeydown}>
|
|
360
|
+
{yearCells.map((cell: FdyYearCell): JSX.Element => (
|
|
361
|
+
<button
|
|
362
|
+
key={cell.year}
|
|
363
|
+
ref={(el: HTMLButtonElement | null): void => { yearRefs.current[cell.year] = el; }}
|
|
364
|
+
type="button"
|
|
365
|
+
className={['fdy-cal__year', cell.today ? 'is-today' : '', cell.selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
|
366
|
+
role="gridcell"
|
|
367
|
+
aria-label={String(cell.year)}
|
|
368
|
+
aria-selected={cell.selected ? true : undefined}
|
|
369
|
+
disabled={cell.disabled}
|
|
370
|
+
tabIndex={cell.focusable ? 0 : -1}
|
|
371
|
+
onClick={(): void => openYear(cell.year)}
|
|
372
|
+
>
|
|
373
|
+
{cell.year}
|
|
374
|
+
</button>
|
|
375
|
+
))}
|
|
376
|
+
</div>
|
|
377
|
+
) : (
|
|
378
|
+
<div className="fdy-cal__grid fdy-cal__grid--months" role="grid" aria-labelledby={titleId} onKeyDown={onMonthKeydown}>
|
|
379
|
+
{monthCells.map((cell: FdyMonthCell): JSX.Element => (
|
|
380
|
+
<button
|
|
381
|
+
key={cell.index}
|
|
382
|
+
ref={(el: HTMLButtonElement | null): void => { monthRefs.current[cell.index] = el; }}
|
|
383
|
+
type="button"
|
|
384
|
+
className={['fdy-cal__month', cell.today ? 'is-today' : '', cell.selected ? 'is-selected' : ''].filter(Boolean).join(' ')}
|
|
385
|
+
role="gridcell"
|
|
386
|
+
aria-label={cell.ariaLabel}
|
|
387
|
+
aria-selected={cell.selected ? true : undefined}
|
|
388
|
+
disabled={cell.disabled}
|
|
389
|
+
tabIndex={cell.focusable ? 0 : -1}
|
|
390
|
+
onClick={(): void => pickMonth(cell.index)}
|
|
391
|
+
>
|
|
392
|
+
{cell.label}
|
|
393
|
+
</button>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
@@ -83,6 +83,13 @@ export interface FdyTableProps<Row extends object> {
|
|
|
83
83
|
* reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
|
|
84
84
|
onPageIndexChange?: (index: number) => void;
|
|
85
85
|
loading?: boolean;
|
|
86
|
+
/** The loading row's text, rendered by this table rather than by the footer. Default `Loading…`.
|
|
87
|
+
* Blazor has carried `LoadingText` all along while the two script adapters wrote the word into
|
|
88
|
+
* the template, so a table in any other language could translate its empty row and not the row
|
|
89
|
+
* right beside it. Defaulted here, like `emptyText` and the four selection strings, and
|
|
90
|
+
* unlike the six footer strings further down, which the footer defaults because the footer is
|
|
91
|
+
* what renders them. */
|
|
92
|
+
loadingText?: string;
|
|
86
93
|
emptyText?: string;
|
|
87
94
|
ariaLabel?: string;
|
|
88
95
|
/** Custom cell renderer; return undefined for a column to use the default text. */
|
|
@@ -128,6 +135,23 @@ export interface FdyTableProps<Row extends object> {
|
|
|
128
135
|
bulkLabel?: string;
|
|
129
136
|
/** Bulk-bar actions (React equivalent of Vue's `bulk-actions` slot). */
|
|
130
137
|
bulkActions?: ReactNode;
|
|
138
|
+
/* The footer's six strings, forwarded to the `FdyTableFooter` this table draws itself. 3.8.0 gave
|
|
139
|
+
* the footer those props and this table passed none of them on, so the hatch reached every footer
|
|
140
|
+
* except the one almost every screen uses, and the documented way out was to switch the footer off
|
|
141
|
+
* and render it yourself: a door that opens only if you stop using the table. Flat props, with the
|
|
142
|
+
* footer's own names, so moving between the two routes moves a prop rather than reshaping it. */
|
|
143
|
+
/** Footer range line, `{from}` `{to}` `{total}` substituted. Default `Showing {from}–{to} of {total}`. */
|
|
144
|
+
infoText?: string;
|
|
145
|
+
/** Footer: the visible word beside the rows-per-page control, which also names it. Default `Rows`. */
|
|
146
|
+
pageSizeLabel?: string;
|
|
147
|
+
/** Footer: accessible name of the pager landmark. Default `Pagination`. */
|
|
148
|
+
paginationLabel?: string;
|
|
149
|
+
/** Footer: accessible name of the previous-page arrow. Default `Previous page`. */
|
|
150
|
+
prevLabel?: string;
|
|
151
|
+
/** Footer: accessible name of the next-page arrow. Default `Next page`. */
|
|
152
|
+
nextLabel?: string;
|
|
153
|
+
/** Footer: accessible name of a numbered page button. `{n}` substituted. Default `Go to page {n}`. */
|
|
154
|
+
pageLabel?: string;
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Element {
|
|
@@ -419,7 +443,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
419
443
|
<tbody>
|
|
420
444
|
{props.loading ? (
|
|
421
445
|
<tr>
|
|
422
|
-
<td colSpan={colCount} className="fdy-table__state" role="status">Loading
|
|
446
|
+
<td colSpan={colCount} className="fdy-table__state" role="status">{props.loadingText ?? 'Loading…'}</td>
|
|
423
447
|
</tr>
|
|
424
448
|
) : displayRows.length === 0 ? (
|
|
425
449
|
<tr>
|
|
@@ -469,8 +493,20 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
|
|
|
469
493
|
</table>
|
|
470
494
|
</div>
|
|
471
495
|
|
|
496
|
+
{/* The six strings are passed as given, `undefined` included: every default lives in the
|
|
497
|
+
footer, so an omitted prop here is the footer's English and not a second copy of it. */}
|
|
472
498
|
{props.pager !== false && (
|
|
473
|
-
<FdyTableFooter
|
|
499
|
+
<FdyTableFooter
|
|
500
|
+
page={footerPage}
|
|
501
|
+
pageSizes={props.pageSizes}
|
|
502
|
+
infoText={props.infoText}
|
|
503
|
+
pageSizeLabel={props.pageSizeLabel}
|
|
504
|
+
paginationLabel={props.paginationLabel}
|
|
505
|
+
prevLabel={props.prevLabel}
|
|
506
|
+
nextLabel={props.nextLabel}
|
|
507
|
+
pageLabel={props.pageLabel}
|
|
508
|
+
onPageChange={onFooterPage}
|
|
509
|
+
/>
|
|
474
510
|
)}
|
|
475
511
|
</div>
|
|
476
512
|
);
|
|
@@ -26,7 +26,19 @@ export function FdyTableFilter(props: FdyTableFilterProps): JSX.Element {
|
|
|
26
26
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
27
27
|
const [open, setOpen] = useState<boolean>(false);
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
/* Always end-aligned, and not an option the parent can set: the funnel is a 24px button in a
|
|
30
|
+
column header while the panel runs past 200px, so lining up their LEFT edges aligns the panel
|
|
31
|
+
with nothing anybody can see. In the rightmost column, the one this was reported from, the
|
|
32
|
+
panel ran past the page and the viewport clamp parked it 4px off the screen edge. A prop no
|
|
33
|
+
caller would ever set to 'start' is a prop that should not exist. The clamp still catches the
|
|
34
|
+
leftmost column, where an end-aligned panel would hang off the other edge.
|
|
35
|
+
`matchWidth:false` for the other half of the same thought. Every other typed dropdown wants a
|
|
36
|
+
panel at least as wide as the control it hangs off; this one hangs off a 24px icon, and that
|
|
37
|
+
floor is written inline, where it outranks the `min-width:14rem` `.fdy-filter` asks for: the
|
|
38
|
+
panel measured 224px on the raw path and 206 here, and an enum column with short labels would
|
|
39
|
+
have gone narrower still. The choice sits in this component rather than in the hook, because a
|
|
40
|
+
hook that guessed it from the trigger's size would be guessing for the other nine callers too. */
|
|
41
|
+
usePopover(panelRef, triggerRef, open, 'end', false);
|
|
30
42
|
// Popover attr for React 18/19 JSX typing: set once on mount (same as the other components).
|
|
31
43
|
useEffect((): void => {
|
|
32
44
|
panelRef.current?.setAttribute('popover', 'manual');
|
|
@@ -25,6 +25,28 @@ export interface FdyTableFooterProps {
|
|
|
25
25
|
* 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
|
export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
@@ -39,6 +61,15 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
|
39
61
|
const rangeTo: number = Math.min(total, (index + 1) * size);
|
|
40
62
|
const pages: Array<number | 'ellipsis'> = pageWindow(currentPage1, totalPages);
|
|
41
63
|
|
|
64
|
+
/* Regex with /g, not String.replace on a string pattern, which stops at the first hit and would
|
|
65
|
+
* drop a second `{total}` a translator wrote. This is what `textOf()` does on the raw path. */
|
|
66
|
+
const infoLine: string = (props.infoText ?? 'Showing {from}–{to} of {total}')
|
|
67
|
+
.replace(/\{from\}/g, String(rangeFrom))
|
|
68
|
+
.replace(/\{to\}/g, String(rangeTo))
|
|
69
|
+
.replace(/\{total\}/g, String(total));
|
|
70
|
+
const pageAriaLabel = (page1: number): string =>
|
|
71
|
+
(props.pageLabel ?? 'Go to page {n}').replace(/\{n\}/g, String(page1));
|
|
72
|
+
|
|
42
73
|
const hasPager: boolean = size > 0 && totalPages > 1;
|
|
43
74
|
const sizes: readonly number[] = props.pageSizes ?? [];
|
|
44
75
|
/* One page and no size control means there is nothing here to say, the table has always withheld
|
|
@@ -58,11 +89,11 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
|
58
89
|
|
|
59
90
|
return (
|
|
60
91
|
<div className="fdy-table-footer">
|
|
61
|
-
<span className="fdy-table-footer__info">
|
|
92
|
+
<span className="fdy-table-footer__info">{infoLine}</span>
|
|
62
93
|
|
|
63
94
|
{sizes.length > 0 && (
|
|
64
95
|
<div className="fdy-table-footer__size">
|
|
65
|
-
<span id={sizeLabelId}>Rows</span>
|
|
96
|
+
<span id={sizeLabelId}>{props.pageSizeLabel ?? 'Rows'}</span>
|
|
66
97
|
{/* The kit's own listbox, not a native <select>: an OS menu is unthemeable, and on macOS
|
|
67
98
|
it drops a dark panel into a light page. FdyCombo's own header says exactly that. */}
|
|
68
99
|
<FdyCombo
|
|
@@ -75,10 +106,10 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
|
75
106
|
)}
|
|
76
107
|
|
|
77
108
|
{hasPager && (
|
|
78
|
-
<nav className="fdy-pagination" aria-label=
|
|
109
|
+
<nav className="fdy-pagination" aria-label={props.paginationLabel ?? 'Pagination'}>
|
|
79
110
|
<ul className="fdy-pagination__list">
|
|
80
111
|
<li>
|
|
81
|
-
<button type="button" className="fdy-pagination__link" aria-label=
|
|
112
|
+
<button type="button" className="fdy-pagination__link" aria-label={props.prevLabel ?? 'Previous page'} disabled={currentPage1 === 1} onClick={(): void => goTo(currentPage1 - 1)}>‹</button>
|
|
82
113
|
</li>
|
|
83
114
|
{pages.map((p: number | 'ellipsis', i: number): JSX.Element => (
|
|
84
115
|
<li key={typeof p === 'number' ? `p-${p}` : `gap-${i}`}>
|
|
@@ -87,12 +118,12 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
|
|
|
87
118
|
) : p === currentPage1 ? (
|
|
88
119
|
<span className="fdy-pagination__link" aria-current="page">{p}</span>
|
|
89
120
|
) : (
|
|
90
|
-
<button type="button" className="fdy-pagination__link" aria-label={
|
|
121
|
+
<button type="button" className="fdy-pagination__link" aria-label={pageAriaLabel(p)} onClick={(): void => goTo(p)}>{p}</button>
|
|
91
122
|
)}
|
|
92
123
|
</li>
|
|
93
124
|
))}
|
|
94
125
|
<li>
|
|
95
|
-
<button type="button" className="fdy-pagination__link" aria-label=
|
|
126
|
+
<button type="button" className="fdy-pagination__link" aria-label={props.nextLabel ?? 'Next page'} disabled={currentPage1 === totalPages} onClick={(): void => goTo(currentPage1 + 1)}>›</button>
|
|
96
127
|
</li>
|
|
97
128
|
</ul>
|
|
98
129
|
</nav>
|
|
@@ -27,9 +27,13 @@ export interface FdyCflSelectDetail { row?: unknown; rows?: unknown[] }
|
|
|
27
27
|
export interface FdyTableChangeDetail { page: number; sort: string | null; query: string }
|
|
28
28
|
|
|
29
29
|
// Re-exports of React components and hooks
|
|
30
|
-
export { usePopover } from './usePopover';
|
|
30
|
+
export { usePopover, type FdyPopoverAlign } from './usePopover';
|
|
31
31
|
export { FdyCombo, type FdyComboProps, type FdyComboOption } from './components/FdyCombo';
|
|
32
|
-
export { FdyDatepicker, type FdyDatepickerProps } from './components/FdyDatepicker';
|
|
32
|
+
export { FdyDatepicker, type FdyDatepickerProps, type FdyDatepickerMode } from './components/FdyDatepicker';
|
|
33
|
+
/** A picker that answers with a whole month, `YYYY-MM`. Replaces `FdyDatepicker`'s deprecated
|
|
34
|
+
* `mode="month"`, where one prop carried two formats and another prop chose between them. */
|
|
35
|
+
export { FdyMonthpicker, type FdyMonthpickerProps } from './components/FdyMonthpicker';
|
|
36
|
+
export type { FdyMonth } from '../core/month-grid';
|
|
33
37
|
export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
|
|
34
38
|
export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
|
|
35
39
|
export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
|
package/adapters/react/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { useFreeday } from './useFreeday.js';
|
|
|
2
2
|
export { usePopover } from './usePopover.ts';
|
|
3
3
|
export { FdyCombo } from './components/FdyCombo.tsx';
|
|
4
4
|
export { FdyDatepicker } from './components/FdyDatepicker.tsx';
|
|
5
|
+
export { FdyMonthpicker } from './components/FdyMonthpicker.tsx';
|
|
5
6
|
export { FdyDateRange } from './components/FdyDateRange.tsx';
|
|
6
7
|
export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
|
|
7
8
|
export { FdyCascade } from './components/FdyCascade.tsx';
|