@cahyo-dimas/freeday 1.7.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 +447 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/adapters/blazor/freeday-blazor.js +79 -0
- package/adapters/react/components/FdyAutocomplete.tsx +159 -0
- package/adapters/react/components/FdyCascade.tsx +230 -0
- package/adapters/react/components/FdyCfl.tsx +362 -0
- package/adapters/react/components/FdyChart.tsx +69 -0
- package/adapters/react/components/FdyCombo.tsx +166 -0
- package/adapters/react/components/FdyDateRange.tsx +66 -0
- package/adapters/react/components/FdyDatepicker.tsx +354 -0
- package/adapters/react/index.d.ts +37 -0
- package/adapters/react/index.js +9 -0
- package/adapters/react/tsconfig.json +14 -0
- package/adapters/react/useFreeday.js +44 -0
- package/adapters/react/usePopover.ts +64 -0
- package/adapters/vue/components/FdyAutocomplete.vue +174 -0
- package/adapters/vue/components/FdyCascade.vue +241 -0
- package/adapters/vue/components/FdyCfl.vue +354 -0
- package/adapters/vue/components/FdyChart.vue +89 -0
- package/adapters/vue/components/FdyCombo.vue +232 -0
- package/adapters/vue/components/FdyDateRange.vue +78 -0
- package/adapters/vue/components/FdyDatepicker.vue +333 -0
- package/adapters/vue/index.d.ts +44 -0
- package/adapters/vue/index.js +8 -0
- package/adapters/vue/useFreeday.js +44 -0
- package/adapters/vue/usePopover.ts +78 -0
- package/dist/freeday-autocomplete.js +135 -0
- package/dist/freeday-breakpoint.js +51 -0
- package/dist/freeday-carousel.js +111 -0
- package/dist/freeday-cascade.js +256 -0
- package/dist/freeday-cfl.js +213 -0
- package/dist/freeday-chart.js +403 -0
- package/dist/freeday-chip.js +83 -0
- package/dist/freeday-datepicker.js +321 -0
- package/dist/freeday-datetime.js +59 -0
- package/dist/freeday-drawer.js +43 -0
- package/dist/freeday-form.js +181 -0
- package/dist/freeday-mask.js +114 -0
- package/dist/freeday-menu.js +93 -0
- package/dist/freeday-popover.js +69 -0
- package/dist/freeday-rating.js +50 -0
- package/dist/freeday-select.js +194 -0
- package/dist/freeday-slider.js +34 -0
- package/dist/freeday-stepper.js +90 -0
- package/dist/freeday-table.js +475 -0
- package/dist/freeday-tabs.js +68 -0
- package/dist/freeday-timepicker.js +180 -0
- package/dist/freeday-toast.js +84 -0
- package/dist/freeday-tree.js +94 -0
- package/dist/freeday-upload.js +206 -0
- package/dist/freeday.bundle.css +1352 -0
- package/dist/freeday.css +1007 -0
- package/dist/freeday.js +3632 -0
- package/dist/freeday.tokens.css +343 -0
- package/package.json +72 -0
- package/src/base.css +20 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/accordion.css +13 -0
- package/src/components/alert.css +18 -0
- package/src/components/app-shell.css +88 -0
- package/src/components/appbar.css +15 -0
- package/src/components/autocomplete.css +12 -0
- package/src/components/avatar.css +8 -0
- package/src/components/badge.css +27 -0
- package/src/components/breadcrumb.css +9 -0
- package/src/components/breakpoints.css +15 -0
- package/src/components/button.css +58 -0
- package/src/components/card.css +9 -0
- package/src/components/carousel.css +17 -0
- package/src/components/cascade.css +36 -0
- package/src/components/cfl.css +28 -0
- package/src/components/chart.css +55 -0
- package/src/components/chip.css +18 -0
- package/src/components/combo.css +27 -0
- package/src/components/datepicker.css +44 -0
- package/src/components/datetimepicker.css +8 -0
- package/src/components/description-list.css +6 -0
- package/src/components/divider.css +3 -0
- package/src/components/drawer.css +25 -0
- package/src/components/file-upload.css +34 -0
- package/src/components/filterbar.css +25 -0
- package/src/components/form-grid.css +8 -0
- package/src/components/input-group.css +22 -0
- package/src/components/input.css +13 -0
- package/src/components/kbd.css +2 -0
- package/src/components/menu.css +28 -0
- package/src/components/modal.css +22 -0
- package/src/components/pagination.css +8 -0
- package/src/components/progress.css +7 -0
- package/src/components/rating.css +10 -0
- package/src/components/selection.css +25 -0
- package/src/components/skeleton.css +7 -0
- package/src/components/slider.css +12 -0
- package/src/components/spinner.css +6 -0
- package/src/components/states.css +8 -0
- package/src/components/stepper.css +21 -0
- package/src/components/table.css +55 -0
- package/src/components/tabs.css +10 -0
- package/src/components/timeline.css +12 -0
- package/src/components/timepicker.css +22 -0
- package/src/components/toast.css +17 -0
- package/src/components/tooltip.css +6 -0
- package/src/components/tree.css +21 -0
- package/src/freeday-autocomplete.js +135 -0
- package/src/freeday-breakpoint.js +51 -0
- package/src/freeday-carousel.js +111 -0
- package/src/freeday-cascade.js +256 -0
- package/src/freeday-cfl.js +213 -0
- package/src/freeday-chart.js +403 -0
- package/src/freeday-chip.js +83 -0
- package/src/freeday-datepicker.js +321 -0
- package/src/freeday-datetime.js +59 -0
- package/src/freeday-drawer.js +43 -0
- package/src/freeday-form.js +181 -0
- package/src/freeday-mask.js +114 -0
- package/src/freeday-menu.js +93 -0
- package/src/freeday-popover.js +69 -0
- package/src/freeday-rating.js +50 -0
- package/src/freeday-select.js +194 -0
- package/src/freeday-slider.js +34 -0
- package/src/freeday-stepper.js +90 -0
- package/src/freeday-table.js +475 -0
- package/src/freeday-tabs.js +68 -0
- package/src/freeday-timepicker.js +180 -0
- package/src/freeday-toast.js +84 -0
- package/src/freeday-tree.js +94 -0
- package/src/freeday-upload.js +206 -0
- package/tokens/breakpoints.mjs +4 -0
- package/tokens/tokens.json +123 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { usePopover } from '../usePopover';
|
|
4
|
+
|
|
5
|
+
// React port of adapters/vue/components/FdyDatepicker.vue over freeday's `.fdy-datepicker` /
|
|
6
|
+
// `.fdy-cal__*` classes (see src/components/datepicker.css). Re-implements the single-date
|
|
7
|
+
// calendar behavior (month grid, keyboard nav, min/max clamping, Intl locale formatting) as a
|
|
8
|
+
// controlled React component (`value` + `onChange` in place of Vue's `v-model`). Open/close
|
|
9
|
+
// (outside-click + focusout) mirrors the already-approved FdyCombo.tsx pattern. Range mode is
|
|
10
|
+
// out of scope (v1 = single date).
|
|
11
|
+
|
|
12
|
+
interface DayCell {
|
|
13
|
+
date: Date;
|
|
14
|
+
iso: string;
|
|
15
|
+
label: number;
|
|
16
|
+
ariaLabel: string;
|
|
17
|
+
outside: boolean;
|
|
18
|
+
today: boolean;
|
|
19
|
+
selected: boolean;
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
focusable: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FdyDatepickerProps {
|
|
25
|
+
value: string | null;
|
|
26
|
+
onChange: (value: string) => void;
|
|
27
|
+
min?: string;
|
|
28
|
+
max?: string;
|
|
29
|
+
locale?: string;
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
invalid?: boolean;
|
|
33
|
+
describedby?: string;
|
|
34
|
+
id?: string;
|
|
35
|
+
ariaLabelledby?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pad(n: number): string {
|
|
39
|
+
return n < 10 ? `0${n}` : `${n}`;
|
|
40
|
+
}
|
|
41
|
+
function toISO(d: Date): string {
|
|
42
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
43
|
+
}
|
|
44
|
+
function parseISO(s: string | null | undefined): Date | null {
|
|
45
|
+
if (s === null || s === undefined || s === '') return null;
|
|
46
|
+
const parts: string[] = s.split('-');
|
|
47
|
+
if (parts.length !== 3) return null;
|
|
48
|
+
const y: number = Number(parts[0]);
|
|
49
|
+
const m: number = Number(parts[1]);
|
|
50
|
+
const day: number = Number(parts[2]);
|
|
51
|
+
const d: Date = new Date(y, m - 1, day);
|
|
52
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
53
|
+
}
|
|
54
|
+
function startOfDay(d: Date): Date {
|
|
55
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
56
|
+
}
|
|
57
|
+
function startOfMonth(d: Date): Date {
|
|
58
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
59
|
+
}
|
|
60
|
+
function sameDay(a: Date | null, b: Date | null): boolean {
|
|
61
|
+
return a !== null && b !== null && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
62
|
+
}
|
|
63
|
+
function addDays(d: Date, n: number): Date {
|
|
64
|
+
const x: Date = new Date(d);
|
|
65
|
+
x.setDate(x.getDate() + n);
|
|
66
|
+
return x;
|
|
67
|
+
}
|
|
68
|
+
function addMonths(d: Date, n: number): Date {
|
|
69
|
+
const x: Date = new Date(d);
|
|
70
|
+
x.setMonth(x.getMonth() + n);
|
|
71
|
+
return x;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dayClassName(cell: DayCell): string {
|
|
75
|
+
let cls = 'fdy-cal__day';
|
|
76
|
+
if (cell.outside) cls += ' is-outside';
|
|
77
|
+
if (cell.today) cls += ' is-today';
|
|
78
|
+
if (cell.selected) cls += ' is-selected';
|
|
79
|
+
return cls;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
83
|
+
const baseId: string = useId();
|
|
84
|
+
const triggerId: string = props.id ?? `${baseId}-trigger`;
|
|
85
|
+
const titleId: string = `${baseId}-title`;
|
|
86
|
+
|
|
87
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
88
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
89
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const dayRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
91
|
+
// Set by moveFocus/openPanel, consumed by the focus-sync effect below (React's analogue of
|
|
92
|
+
// Vue's `nextTick(focusCell)`); nav-button clicks intentionally leave this untouched so they
|
|
93
|
+
// don't steal DOM focus off the button, matching the Vue template's behavior.
|
|
94
|
+
const pendingFocusRef = useRef<string | null>(null);
|
|
95
|
+
|
|
96
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
97
|
+
|
|
98
|
+
// Render the calendar panel in the top layer (popover) so it never gets clipped by a card or
|
|
99
|
+
// scroll container; positions against the trigger.
|
|
100
|
+
usePopover(panelRef, triggerRef, open);
|
|
101
|
+
|
|
102
|
+
// Popover attr for React 18 (JSX may not type it): set once on mount.
|
|
103
|
+
useEffect((): void => {
|
|
104
|
+
panelRef.current?.setAttribute('popover', 'manual');
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const selectedDate: Date | null = useMemo((): Date | null => parseISO(props.value), [props.value]);
|
|
108
|
+
const minDate: Date | null = useMemo((): Date | null => parseISO(props.min), [props.min]);
|
|
109
|
+
const maxDate: Date | null = useMemo((): Date | null => parseISO(props.max), [props.max]);
|
|
110
|
+
const isDisabled: boolean = props.disabled === true;
|
|
111
|
+
const isInvalid: boolean = props.invalid === true;
|
|
112
|
+
const displayPlaceholder: string = props.placeholder ?? 'Pilih tanggal';
|
|
113
|
+
|
|
114
|
+
const [viewMonth, setViewMonth] = useState<Date>((): Date => startOfMonth(parseISO(props.value) ?? new Date()));
|
|
115
|
+
const [focusDate, setFocusDate] = useState<Date>((): Date => parseISO(props.value) ?? new Date());
|
|
116
|
+
|
|
117
|
+
// Keep the roving-tabindex cell inside the visible month so the grid stays keyboard-reachable
|
|
118
|
+
// after navigating with Home/End/PageUp/PageDown (mirrors freeday-datepicker.js's clamp).
|
|
119
|
+
const effectiveFocusDate: Date = useMemo((): Date => {
|
|
120
|
+
if (focusDate.getMonth() === viewMonth.getMonth() && focusDate.getFullYear() === viewMonth.getFullYear()) return focusDate;
|
|
121
|
+
const daysInMonth: number = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 0).getDate();
|
|
122
|
+
return new Date(viewMonth.getFullYear(), viewMonth.getMonth(), Math.min(focusDate.getDate(), daysInMonth));
|
|
123
|
+
}, [focusDate, viewMonth]);
|
|
124
|
+
|
|
125
|
+
const monthFmt: Intl.DateTimeFormat = useMemo(
|
|
126
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }),
|
|
127
|
+
[props.locale],
|
|
128
|
+
);
|
|
129
|
+
const valueFmt: Intl.DateTimeFormat = useMemo(
|
|
130
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { day: '2-digit', month: 'short', year: 'numeric' }),
|
|
131
|
+
[props.locale],
|
|
132
|
+
);
|
|
133
|
+
const dayLabelFmt: Intl.DateTimeFormat = useMemo(
|
|
134
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
|
|
135
|
+
[props.locale],
|
|
136
|
+
);
|
|
137
|
+
const dowFmt: Intl.DateTimeFormat = useMemo(
|
|
138
|
+
(): Intl.DateTimeFormat => new Intl.DateTimeFormat(props.locale, { weekday: 'short' }),
|
|
139
|
+
[props.locale],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const displayValue: string = selectedDate !== null ? valueFmt.format(selectedDate) : displayPlaceholder;
|
|
143
|
+
|
|
144
|
+
const weekdayHeaders: string[] = useMemo((): string[] => {
|
|
145
|
+
const monday: Date = new Date(2024, 0, 1); // a known Monday
|
|
146
|
+
return Array.from({ length: 7 }, (_unused: unknown, i: number): string => dowFmt.format(addDays(monday, i)));
|
|
147
|
+
}, [dowFmt]);
|
|
148
|
+
|
|
149
|
+
function isDayDisabled(d: Date): boolean {
|
|
150
|
+
if (minDate !== null && startOfDay(d).getTime() < startOfDay(minDate).getTime()) return true;
|
|
151
|
+
if (maxDate !== null && startOfDay(d).getTime() > startOfDay(maxDate).getTime()) return true;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const gridCells: DayCell[] = useMemo((): DayCell[] => {
|
|
156
|
+
const first: Date = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
|
|
157
|
+
const offset: number = (first.getDay() + 6) % 7; // Monday-first
|
|
158
|
+
const startCell: Date = addDays(first, -offset);
|
|
159
|
+
const today: Date = new Date();
|
|
160
|
+
const focus: Date = effectiveFocusDate;
|
|
161
|
+
const cells: DayCell[] = [];
|
|
162
|
+
for (let i = 0; i < 42; i++) {
|
|
163
|
+
const d: Date = addDays(startCell, i);
|
|
164
|
+
cells.push({
|
|
165
|
+
date: d,
|
|
166
|
+
iso: toISO(d),
|
|
167
|
+
label: d.getDate(),
|
|
168
|
+
ariaLabel: dayLabelFmt.format(d),
|
|
169
|
+
outside: d.getMonth() !== viewMonth.getMonth(),
|
|
170
|
+
today: sameDay(d, today),
|
|
171
|
+
selected: sameDay(d, selectedDate),
|
|
172
|
+
disabled: isDayDisabled(d),
|
|
173
|
+
focusable: sameDay(d, focus),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return cells;
|
|
177
|
+
// Deps intentionally mirror the port recipe, not every closure read: minDate/maxDate,
|
|
178
|
+
// dayLabelFmt and effectiveFocusDate all derive deterministically from the listed deps
|
|
179
|
+
// within the same render pass, so this stays in sync without listing them separately.
|
|
180
|
+
}, [viewMonth, props.value, props.min, props.max, props.locale]);
|
|
181
|
+
|
|
182
|
+
// Focus the roving-tabindex cell after a render triggered by moveFocus/openPanel (React's
|
|
183
|
+
// analogue of Vue's `nextTick(focusCell)`). Runs after every render; cheap no-op otherwise.
|
|
184
|
+
useEffect((): void => {
|
|
185
|
+
if (pendingFocusRef.current === null) return;
|
|
186
|
+
const iso: string = pendingFocusRef.current;
|
|
187
|
+
pendingFocusRef.current = null;
|
|
188
|
+
dayRefs.current[iso]?.focus();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
function moveFocus(next: Date): void {
|
|
192
|
+
setFocusDate(next);
|
|
193
|
+
setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
|
|
194
|
+
pendingFocusRef.current = toISO(next);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pick(d: Date): void {
|
|
198
|
+
if (isDayDisabled(d)) return;
|
|
199
|
+
const day: Date = startOfDay(d);
|
|
200
|
+
setFocusDate(day);
|
|
201
|
+
const iso: string = toISO(day);
|
|
202
|
+
props.onChange(iso);
|
|
203
|
+
closePanel(true);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function openPanel(): void {
|
|
207
|
+
if (isDisabled || open) return;
|
|
208
|
+
const next: Date = selectedDate ?? focusDate ?? new Date();
|
|
209
|
+
setFocusDate(next);
|
|
210
|
+
setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
|
|
211
|
+
setOpen(true);
|
|
212
|
+
pendingFocusRef.current = toISO(next);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function closePanel(returnFocus: boolean): void {
|
|
216
|
+
if (!open) return;
|
|
217
|
+
setOpen(false);
|
|
218
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function toggle(): void {
|
|
222
|
+
if (open) closePanel(false);
|
|
223
|
+
else openPanel();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onGridKeydown(e: React.KeyboardEvent<HTMLDivElement>): void {
|
|
227
|
+
const f: Date = effectiveFocusDate;
|
|
228
|
+
switch (e.key) {
|
|
229
|
+
case 'ArrowLeft':
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
moveFocus(addDays(f, -1));
|
|
232
|
+
break;
|
|
233
|
+
case 'ArrowRight':
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
moveFocus(addDays(f, 1));
|
|
236
|
+
break;
|
|
237
|
+
case 'ArrowUp':
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
moveFocus(addDays(f, -7));
|
|
240
|
+
break;
|
|
241
|
+
case 'ArrowDown':
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
moveFocus(addDays(f, 7));
|
|
244
|
+
break;
|
|
245
|
+
case 'Home':
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
moveFocus(addDays(f, -((f.getDay() + 6) % 7)));
|
|
248
|
+
break;
|
|
249
|
+
case 'End':
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
moveFocus(addDays(f, 6 - ((f.getDay() + 6) % 7)));
|
|
252
|
+
break;
|
|
253
|
+
case 'PageUp':
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
moveFocus(addMonths(f, e.shiftKey ? -12 : -1));
|
|
256
|
+
break;
|
|
257
|
+
case 'PageDown':
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
moveFocus(addMonths(f, e.shiftKey ? 12 : 1));
|
|
260
|
+
break;
|
|
261
|
+
case 'Enter':
|
|
262
|
+
case ' ':
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
pick(f);
|
|
265
|
+
break;
|
|
266
|
+
case 'Escape':
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
closePanel(true);
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Close when focus leaves the control entirely (e.g. Shift+Tab off the trigger).
|
|
276
|
+
const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
|
|
277
|
+
const next: EventTarget | null = e.relatedTarget;
|
|
278
|
+
if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closePanel(false);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Close when a pointer lands outside the whole control.
|
|
282
|
+
useEffect((): void | (() => void) => {
|
|
283
|
+
if (!open) return;
|
|
284
|
+
const onDocPointerDown = (e: MouseEvent): void => {
|
|
285
|
+
const t: EventTarget | null = e.target;
|
|
286
|
+
if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closePanel(false);
|
|
287
|
+
};
|
|
288
|
+
document.addEventListener('mousedown', onDocPointerDown);
|
|
289
|
+
return (): void => document.removeEventListener('mousedown', onDocPointerDown);
|
|
290
|
+
}, [open]);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'} onBlur={onFocusout}>
|
|
294
|
+
<button
|
|
295
|
+
id={triggerId}
|
|
296
|
+
ref={triggerRef}
|
|
297
|
+
type="button"
|
|
298
|
+
className={open ? 'fdy-datepicker__trigger is-open' : 'fdy-datepicker__trigger'}
|
|
299
|
+
aria-haspopup="dialog"
|
|
300
|
+
aria-expanded={open}
|
|
301
|
+
aria-labelledby={props.ariaLabelledby}
|
|
302
|
+
aria-invalid={isInvalid ? 'true' : undefined}
|
|
303
|
+
aria-describedby={props.describedby}
|
|
304
|
+
disabled={isDisabled}
|
|
305
|
+
onClick={toggle}
|
|
306
|
+
>
|
|
307
|
+
<span className={selectedDate === null ? 'fdy-datepicker__value fdy-datepicker__value--placeholder' : 'fdy-datepicker__value'}>
|
|
308
|
+
{displayValue}
|
|
309
|
+
</span>
|
|
310
|
+
<span className="fdy-datepicker__icon" aria-hidden="true">
|
|
311
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
312
|
+
<rect x="3" y="4" width="18" height="18" rx="2"></rect>
|
|
313
|
+
<path d="M16 2v4M8 2v4M3 10h18"></path>
|
|
314
|
+
</svg>
|
|
315
|
+
</span>
|
|
316
|
+
</button>
|
|
317
|
+
|
|
318
|
+
<div ref={panelRef} className="fdy-datepicker__panel" role="dialog" aria-modal="false" aria-labelledby={titleId} hidden={!open}>
|
|
319
|
+
<div className="fdy-cal__head">
|
|
320
|
+
<button type="button" className="fdy-cal__nav" aria-label="Bulan sebelumnya" onClick={(): void => setViewMonth(addMonths(viewMonth, -1))}>
|
|
321
|
+
‹
|
|
322
|
+
</button>
|
|
323
|
+
<div id={titleId} className="fdy-cal__title">{monthFmt.format(viewMonth)}</div>
|
|
324
|
+
<button type="button" className="fdy-cal__nav" aria-label="Bulan berikutnya" onClick={(): void => setViewMonth(addMonths(viewMonth, 1))}>
|
|
325
|
+
›
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
<div className="fdy-cal__grid" role="grid" aria-labelledby={titleId} onKeyDown={onGridKeydown}>
|
|
329
|
+
{weekdayHeaders.map((w: string): JSX.Element => (
|
|
330
|
+
<div key={w} className="fdy-cal__dow" role="columnheader" aria-label={w}>{w}</div>
|
|
331
|
+
))}
|
|
332
|
+
{gridCells.map((cell: DayCell): JSX.Element => (
|
|
333
|
+
<button
|
|
334
|
+
key={cell.iso}
|
|
335
|
+
ref={(el: HTMLButtonElement | null): void => {
|
|
336
|
+
dayRefs.current[cell.iso] = el;
|
|
337
|
+
}}
|
|
338
|
+
type="button"
|
|
339
|
+
className={dayClassName(cell)}
|
|
340
|
+
role="gridcell"
|
|
341
|
+
aria-label={cell.ariaLabel}
|
|
342
|
+
aria-selected={cell.selected ? 'true' : undefined}
|
|
343
|
+
disabled={cell.disabled}
|
|
344
|
+
tabIndex={cell.focusable ? 0 : -1}
|
|
345
|
+
onClick={(): void => pick(cell.date)}
|
|
346
|
+
>
|
|
347
|
+
{cell.label}
|
|
348
|
+
</button>
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UseFreedayReturn {
|
|
4
|
+
/** Manually re-run all Freeday enhancers over the scoped subtree. */
|
|
5
|
+
rehydrate: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Auto-initialise Freeday enhancers over a component's subtree on mount and
|
|
10
|
+
* after every commit. Pass a ref to scope the work; omit it to scan the whole
|
|
11
|
+
* document.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useFreeday(rootRef?: RefObject<HTMLElement | null>): UseFreedayReturn;
|
|
14
|
+
|
|
15
|
+
/** `event.detail` shapes for the bubbling `fdy-*` CustomEvents. */
|
|
16
|
+
export interface FdyChangeDetail { value: string }
|
|
17
|
+
export interface FdyAutocompleteSelectDetail { value: string }
|
|
18
|
+
export interface FdyCascadeChangeDetail { value: string; path: string; labels: string[] }
|
|
19
|
+
export interface FdyDatepickerChangeDetail { value: string; date: Date | null }
|
|
20
|
+
export interface FdyTimeSelectDetail { value: string }
|
|
21
|
+
export interface FdyDatetimeChangeDetail { date: string; time: string; value: string }
|
|
22
|
+
export interface FdyMaskDetail { value: string; raw: string }
|
|
23
|
+
export interface FdyFormInvalidDetail { invalid: HTMLElement[] }
|
|
24
|
+
export interface FdyChipChangeDetail { value: string; pressed: boolean; selected: string[] }
|
|
25
|
+
export interface FdyChipRemoveDetail { value: string }
|
|
26
|
+
export interface FdyCflSelectDetail { row?: unknown; rows?: unknown[] }
|
|
27
|
+
export interface FdyTableChangeDetail { page: number; sort: string | null; query: string }
|
|
28
|
+
|
|
29
|
+
// Re-exports of React components and hooks
|
|
30
|
+
export { usePopover } from './usePopover';
|
|
31
|
+
export { FdyCombo, type FdyComboProps, type FdyComboOption } from './components/FdyCombo';
|
|
32
|
+
export { FdyDatepicker, type FdyDatepickerProps } from './components/FdyDatepicker';
|
|
33
|
+
export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
|
|
34
|
+
export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
|
|
35
|
+
export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
|
|
36
|
+
export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
|
|
37
|
+
export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { useFreeday } from './useFreeday.js';
|
|
2
|
+
export { usePopover } from './usePopover.ts';
|
|
3
|
+
export { FdyCombo } from './components/FdyCombo.tsx';
|
|
4
|
+
export { FdyDatepicker } from './components/FdyDatepicker.tsx';
|
|
5
|
+
export { FdyDateRange } from './components/FdyDateRange.tsx';
|
|
6
|
+
export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
|
|
7
|
+
export { FdyCascade } from './components/FdyCascade.tsx';
|
|
8
|
+
export { FdyCfl } from './components/FdyCfl.tsx';
|
|
9
|
+
export { FdyChart } from './components/FdyChart.tsx';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["react"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* Freeday — React adapter (thin). The enhancers stay the source of truth; this
|
|
2
|
+
* hook re-runs every registered window.Freeday*.initAll() over a subtree after
|
|
3
|
+
* React commits, so [data-fdy-*] markup rendered by React gets enhanced (and
|
|
4
|
+
* re-enhanced after updates). Enhancers are idempotent, so the repeated calls
|
|
5
|
+
* are safe. Requires `import 'freeday'` once at app entry to register them.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const root = useRef(null);
|
|
9
|
+
* useFreeday(root); // scopes init to this component's subtree
|
|
10
|
+
* // <div ref={root}> ...[data-fdy-*] markup... </div>
|
|
11
|
+
*
|
|
12
|
+
* Events are native bubbling CustomEvents — add listeners on the root (they
|
|
13
|
+
* bubble) and read event.detail. See index.d.ts for the detail types.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useCallback } from 'react';
|
|
16
|
+
|
|
17
|
+
// Discover Freeday enhancer namespaces once (all registered by the time an app
|
|
18
|
+
// mounts, since `import 'freeday'` runs first). Each exposes initAll(el?).
|
|
19
|
+
var _apis = null;
|
|
20
|
+
function freedayApis() {
|
|
21
|
+
if (_apis) return _apis;
|
|
22
|
+
_apis = [];
|
|
23
|
+
var names = Object.getOwnPropertyNames(window);
|
|
24
|
+
for (var i = 0; i < names.length; i++) {
|
|
25
|
+
var key = names[i];
|
|
26
|
+
if (key.slice(0, 7) === 'Freeday') {
|
|
27
|
+
var api = window[key];
|
|
28
|
+
if (api && typeof api.initAll === 'function') _apis.push(api);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return _apis;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useFreeday(rootRef) {
|
|
35
|
+
var rehydrate = useCallback(function () {
|
|
36
|
+
var el = rootRef && rootRef.current ? rootRef.current : undefined;
|
|
37
|
+
var apis = freedayApis();
|
|
38
|
+
for (var i = 0; i < apis.length; i++) apis[i].initAll(el);
|
|
39
|
+
}, [rootRef]);
|
|
40
|
+
// No dependency array: runs after every commit (mount + updates), mirroring
|
|
41
|
+
// the Vue adapter's onMounted + onUpdated.
|
|
42
|
+
useEffect(rehydrate);
|
|
43
|
+
return { rehydrate: rehydrate };
|
|
44
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
// React port of adapters/vue/usePopover.ts. Lifts a dropdown panel into the top layer via the
|
|
4
|
+
// native Popover API so it escapes any ancestor overflow clip (a .fdy-card, a scroll container),
|
|
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 [hidden]/absolute CSS where Popover is unavailable.
|
|
7
|
+
|
|
8
|
+
interface PopoverElement extends HTMLElement {
|
|
9
|
+
showPopover(): void;
|
|
10
|
+
hidePopover(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const GAP = 4; // ~ --space-1
|
|
14
|
+
const supported: boolean =
|
|
15
|
+
typeof HTMLElement !== 'undefined'
|
|
16
|
+
&& typeof (HTMLElement.prototype as Partial<PopoverElement>).showPopover === 'function';
|
|
17
|
+
|
|
18
|
+
function place(panel: HTMLElement, trigger: HTMLElement): void {
|
|
19
|
+
const r: DOMRect = trigger.getBoundingClientRect();
|
|
20
|
+
panel.style.position = 'fixed';
|
|
21
|
+
panel.style.margin = '0';
|
|
22
|
+
panel.style.inset = 'auto';
|
|
23
|
+
panel.style.minWidth = `${r.width}px`;
|
|
24
|
+
const ph: number = panel.offsetHeight;
|
|
25
|
+
const pw: number = panel.offsetWidth;
|
|
26
|
+
const vw: number = document.documentElement.clientWidth;
|
|
27
|
+
const vh: number = document.documentElement.clientHeight;
|
|
28
|
+
const below: number = vh - r.bottom - GAP;
|
|
29
|
+
const above: number = r.top - GAP;
|
|
30
|
+
const top: number = ph <= below || below >= above ? r.bottom + GAP : Math.max(GAP, r.top - GAP - ph);
|
|
31
|
+
let left: number = r.left;
|
|
32
|
+
if (left + pw > vw - GAP) left = Math.max(GAP, vw - GAP - pw);
|
|
33
|
+
panel.style.top = `${Math.round(top)}px`;
|
|
34
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function usePopover(
|
|
38
|
+
panelRef: RefObject<HTMLElement | null>,
|
|
39
|
+
triggerRef: RefObject<HTMLElement | null>,
|
|
40
|
+
open: boolean,
|
|
41
|
+
): void {
|
|
42
|
+
useEffect((): void | (() => void) => {
|
|
43
|
+
const panel: PopoverElement | null = panelRef.current as PopoverElement | null;
|
|
44
|
+
const trigger: HTMLElement | null = triggerRef.current;
|
|
45
|
+
if (!supported || panel === null || trigger === null) return;
|
|
46
|
+
|
|
47
|
+
function reposition(): void {
|
|
48
|
+
if (panel !== null && trigger !== null && panel.matches(':popover-open')) place(panel, trigger);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (open) {
|
|
52
|
+
if (!panel.matches(':popover-open')) panel.showPopover();
|
|
53
|
+
place(panel, trigger);
|
|
54
|
+
window.addEventListener('scroll', reposition, true);
|
|
55
|
+
window.addEventListener('resize', reposition);
|
|
56
|
+
return (): void => {
|
|
57
|
+
window.removeEventListener('scroll', reposition, true);
|
|
58
|
+
window.removeEventListener('resize', reposition);
|
|
59
|
+
if (panel.matches(':popover-open')) panel.hidePopover();
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}, [open, panelRef, triggerRef]);
|
|
64
|
+
}
|