@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,362 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
// React port of adapters/vue/components/FdyCfl.vue over freeday's `.fdy-cfl*` +
|
|
5
|
+
// `.fdy-input-group` classes (see src/components/cfl.css, input-group.css). A controlled
|
|
6
|
+
// async choose-from-list: `value: Row | null` + `onChange(row)` in place of Vue's
|
|
7
|
+
// `v-model:Row|null`, driving a native <dialog> whose rows come from the caller's
|
|
8
|
+
// `fetchPage(query, page)`. Re-implements the Vue source's open/close + debounced search +
|
|
9
|
+
// dense sticky-header results + single-commit-on-click + keyboard, plus the async-correctness
|
|
10
|
+
// pieces a server picker needs: loading/empty/error states, retry, pagination, an
|
|
11
|
+
// out-of-order fetch guard, and an in-memory cache cleared on open/close.
|
|
12
|
+
|
|
13
|
+
export interface CflColumn<Row> {
|
|
14
|
+
key: keyof Row & string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CflPage<Row> {
|
|
19
|
+
rows: Row[];
|
|
20
|
+
hasMore: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FdyCflProps<Row extends Record<string, unknown>> {
|
|
24
|
+
value: Row | null;
|
|
25
|
+
onChange: (value: Row) => void;
|
|
26
|
+
fetchPage: (query: string, page: number) => Promise<CflPage<Row>>;
|
|
27
|
+
columns: ReadonlyArray<CflColumn<Row>>;
|
|
28
|
+
display: (row: Row) => string;
|
|
29
|
+
rowKey: (row: Row) => string;
|
|
30
|
+
/** Advisory only — the caller's `fetchPage` owns paging; kept for API documentation. */
|
|
31
|
+
pageSize?: number;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
invalid?: boolean;
|
|
35
|
+
describedby?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
ariaLabelledby?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<Row>): JSX.Element {
|
|
41
|
+
const baseId: string = useId();
|
|
42
|
+
const fieldId: string = props.id ?? `${baseId}-field`;
|
|
43
|
+
const titleId: string = `${baseId}-title`;
|
|
44
|
+
const resultsId: string = `${baseId}-results`;
|
|
45
|
+
const rowId = (index: number): string => `${baseId}-row-${index}`;
|
|
46
|
+
|
|
47
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
48
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
49
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
50
|
+
|
|
51
|
+
const [query, setQuery] = useState<string>('');
|
|
52
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
53
|
+
const [page, setPage] = useState<number>(0);
|
|
54
|
+
const [hasMore, setHasMore] = useState<boolean>(false);
|
|
55
|
+
const [loading, setLoading] = useState<boolean>(false);
|
|
56
|
+
const [error, setError] = useState<Error | null>(null);
|
|
57
|
+
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
|
58
|
+
|
|
59
|
+
// Out-of-order guard: every fetch takes a monotonically increasing token; a resolved or
|
|
60
|
+
// rejected page is applied only if it still owns the latest token. A newer search, a
|
|
61
|
+
// load-more, a dialog close, or an unmount all bump it — this keeps a slow stale response
|
|
62
|
+
// from overwriting fresh results (or a closed/unmounted component's state).
|
|
63
|
+
const reqIdRef = useRef<number>(0);
|
|
64
|
+
// In-memory cache keyed by `${query}::${page}`, cleared on open and on close.
|
|
65
|
+
const cacheRef = useRef<Map<string, CflPage<Row>>>(new Map<string, CflPage<Row>>());
|
|
66
|
+
const lastPageRef = useRef<number>(0);
|
|
67
|
+
const lastAppendRef = useRef<boolean>(false);
|
|
68
|
+
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
69
|
+
|
|
70
|
+
const isDisabled: boolean = props.disabled === true;
|
|
71
|
+
const isInvalid: boolean = props.invalid === true;
|
|
72
|
+
const displayValue: string = props.value !== null ? props.display(props.value) : '';
|
|
73
|
+
// The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
|
|
74
|
+
// search input's aria refs on rows existing — otherwise they'd dangle during loading/empty/error.
|
|
75
|
+
const hasRows: boolean = rows.length > 0;
|
|
76
|
+
const controlsId: string | undefined = hasRows ? resultsId : undefined;
|
|
77
|
+
const activeDescendant: string | undefined = hasRows && activeIndex >= 0 ? rowId(activeIndex) : undefined;
|
|
78
|
+
const isInitialLoading: boolean = loading && rows.length === 0;
|
|
79
|
+
const isBlockingError: boolean = error !== null && rows.length === 0;
|
|
80
|
+
const isEmpty: boolean = !loading && error === null && rows.length === 0;
|
|
81
|
+
|
|
82
|
+
// --- Async engine ----------------------------------------------------------------------
|
|
83
|
+
async function load(targetQuery: string, targetPage: number, append: boolean): Promise<void> {
|
|
84
|
+
lastPageRef.current = targetPage;
|
|
85
|
+
lastAppendRef.current = append;
|
|
86
|
+
const token: number = ++reqIdRef.current;
|
|
87
|
+
const key = `${targetQuery}::${targetPage}`;
|
|
88
|
+
setError(null);
|
|
89
|
+
setLoading(true);
|
|
90
|
+
try {
|
|
91
|
+
const cached: CflPage<Row> | undefined = cacheRef.current.get(key);
|
|
92
|
+
const res: CflPage<Row> = cached ?? (await props.fetchPage(targetQuery, targetPage));
|
|
93
|
+
if (token !== reqIdRef.current) return; // stale — a newer request has started
|
|
94
|
+
if (cached === undefined) cacheRef.current.set(key, res);
|
|
95
|
+
const copy: Row[] = res.rows.slice(); // never mutate the caller's array
|
|
96
|
+
setRows((prev: Row[]): Row[] => (append ? prev.concat(copy) : copy));
|
|
97
|
+
setHasMore(res.hasMore);
|
|
98
|
+
setPage(targetPage);
|
|
99
|
+
if (!append) setActiveIndex(copy.length > 0 ? 0 : -1);
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
if (token !== reqIdRef.current) return;
|
|
102
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
103
|
+
} finally {
|
|
104
|
+
if (token === reqIdRef.current) setLoading(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Mirrors Vue's `retry()`: only `lastPage`/`lastAppend` are frozen from the failed request —
|
|
109
|
+
// the query is re-read live so a retry after editing the search box (within the debounce
|
|
110
|
+
// window) uses what's currently typed, not a stale snapshot.
|
|
111
|
+
function retry(): void {
|
|
112
|
+
void load(query, lastPageRef.current, lastAppendRef.current);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadMore(): void {
|
|
116
|
+
if (loading || !hasMore) return;
|
|
117
|
+
void load(query, page + 1, true);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onSearchInput(e: React.ChangeEvent<HTMLInputElement>): void {
|
|
121
|
+
const value: string = e.target.value;
|
|
122
|
+
setQuery(value);
|
|
123
|
+
if (searchTimerRef.current !== null) clearTimeout(searchTimerRef.current);
|
|
124
|
+
searchTimerRef.current = setTimeout((): void => {
|
|
125
|
+
searchTimerRef.current = null;
|
|
126
|
+
void load(value, 0, false);
|
|
127
|
+
}, 250);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Active-row / keyboard ---------------------------------------------------------------
|
|
131
|
+
function setActive(index: number): void {
|
|
132
|
+
if (rows.length === 0) {
|
|
133
|
+
setActiveIndex(-1);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const clamped: number = Math.max(0, Math.min(index, rows.length - 1));
|
|
137
|
+
setActiveIndex(clamped);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Scroll the active row into view once it (re)renders — React's analogue of Vue's
|
|
141
|
+
// `nextTick(() => scrollIntoView(...))`; fires for both keyboard nav and row hover, same as
|
|
142
|
+
// the Vue source's `setActive`.
|
|
143
|
+
useEffect((): void => {
|
|
144
|
+
if (activeIndex < 0) return;
|
|
145
|
+
document.getElementById(rowId(activeIndex))?.scrollIntoView({ block: 'nearest' });
|
|
146
|
+
}, [activeIndex]);
|
|
147
|
+
|
|
148
|
+
function commit(row: Row): void {
|
|
149
|
+
props.onChange(row);
|
|
150
|
+
closeDialog();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function onKeydown(e: React.KeyboardEvent<HTMLDialogElement>): void {
|
|
154
|
+
switch (e.key) {
|
|
155
|
+
case 'ArrowDown':
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
setActive(activeIndex + 1);
|
|
158
|
+
break;
|
|
159
|
+
case 'ArrowUp':
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
setActive(activeIndex - 1);
|
|
162
|
+
break;
|
|
163
|
+
case 'Home':
|
|
164
|
+
if (rows.length > 0) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
setActive(0);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case 'End':
|
|
170
|
+
if (rows.length > 0) {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
setActive(rows.length - 1);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'Enter': {
|
|
176
|
+
const row: Row | undefined = activeIndex >= 0 ? rows[activeIndex] : undefined;
|
|
177
|
+
if (row !== undefined) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
commit(row);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
break; // Escape is left to the native <dialog> cancel/close
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- Open / close ------------------------------------------------------------------------
|
|
189
|
+
function openDialog(): void {
|
|
190
|
+
if (isDisabled) return;
|
|
191
|
+
if (searchTimerRef.current !== null) {
|
|
192
|
+
clearTimeout(searchTimerRef.current);
|
|
193
|
+
searchTimerRef.current = null;
|
|
194
|
+
}
|
|
195
|
+
cacheRef.current.clear();
|
|
196
|
+
setQuery('');
|
|
197
|
+
setRows([]);
|
|
198
|
+
setPage(0);
|
|
199
|
+
setHasMore(false);
|
|
200
|
+
setError(null);
|
|
201
|
+
setActiveIndex(-1);
|
|
202
|
+
dialogRef.current?.showModal();
|
|
203
|
+
searchRef.current?.focus();
|
|
204
|
+
void load('', 0, false);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function closeDialog(): void {
|
|
208
|
+
dialogRef.current?.close();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Fires for every close path (button, Esc, commit); centralise cleanup here.
|
|
212
|
+
function onClose(): void {
|
|
213
|
+
reqIdRef.current++; // invalidate any in-flight fetch so it can't apply after close
|
|
214
|
+
if (searchTimerRef.current !== null) {
|
|
215
|
+
// Cancel a debounce armed just before close (e.g. typed a char then clicked a row) so
|
|
216
|
+
// it can't fire a stray fetchPage after the dialog is gone.
|
|
217
|
+
clearTimeout(searchTimerRef.current);
|
|
218
|
+
searchTimerRef.current = null;
|
|
219
|
+
}
|
|
220
|
+
cacheRef.current.clear();
|
|
221
|
+
setLoading(false);
|
|
222
|
+
triggerRef.current?.focus();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cellText(row: Row, key: keyof Row & string): string {
|
|
226
|
+
const value: unknown = row[key];
|
|
227
|
+
return value === null || value === undefined ? '' : String(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Unmount safety: bump the token so any in-flight fetch's setState is dropped, and cancel a
|
|
231
|
+
// pending debounce — mirrors Vue's onBeforeUnmount plus the same token trick onClose uses.
|
|
232
|
+
useEffect((): (() => void) => {
|
|
233
|
+
return (): void => {
|
|
234
|
+
reqIdRef.current++;
|
|
235
|
+
if (searchTimerRef.current !== null) clearTimeout(searchTimerRef.current);
|
|
236
|
+
};
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="fdy-input-group">
|
|
241
|
+
<input
|
|
242
|
+
id={fieldId}
|
|
243
|
+
className="fdy-input"
|
|
244
|
+
type="text"
|
|
245
|
+
readOnly
|
|
246
|
+
value={displayValue}
|
|
247
|
+
placeholder={props.placeholder}
|
|
248
|
+
aria-labelledby={props.ariaLabelledby}
|
|
249
|
+
aria-invalid={isInvalid ? 'true' : undefined}
|
|
250
|
+
aria-describedby={props.describedby}
|
|
251
|
+
disabled={isDisabled}
|
|
252
|
+
/>
|
|
253
|
+
<button
|
|
254
|
+
ref={triggerRef}
|
|
255
|
+
type="button"
|
|
256
|
+
className="fdy-input-group__btn"
|
|
257
|
+
aria-haspopup="dialog"
|
|
258
|
+
aria-labelledby={props.ariaLabelledby}
|
|
259
|
+
aria-label={props.ariaLabelledby ? undefined : 'Buka pencarian'}
|
|
260
|
+
disabled={isDisabled}
|
|
261
|
+
onClick={openDialog}
|
|
262
|
+
>
|
|
263
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
264
|
+
<circle cx={11} cy={11} r={7}></circle>
|
|
265
|
+
<path d="m21 21-4.35-4.35"></path>
|
|
266
|
+
</svg>
|
|
267
|
+
</button>
|
|
268
|
+
|
|
269
|
+
<dialog ref={dialogRef} className="fdy-modal fdy-modal--cfl" aria-labelledby={titleId} onClose={onClose} onKeyDown={onKeydown}>
|
|
270
|
+
<div className="fdy-modal__header">
|
|
271
|
+
<h3 id={titleId} className="fdy-modal__title">Pilih data</h3>
|
|
272
|
+
<button className="fdy-modal__close" type="button" aria-label="Tutup" onClick={closeDialog}>×</button>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div className="fdy-modal__body">
|
|
276
|
+
<div className="fdy-cfl__search">
|
|
277
|
+
<div className="fdy-input-group" style={{ maxWidth: 'none' }}>
|
|
278
|
+
<span className="fdy-input-group__addon fdy-input-group__addon--icon">
|
|
279
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
280
|
+
<circle cx={11} cy={11} r={7}></circle>
|
|
281
|
+
<path d="m21 21-4.35-4.35"></path>
|
|
282
|
+
</svg>
|
|
283
|
+
</span>
|
|
284
|
+
<input
|
|
285
|
+
ref={searchRef}
|
|
286
|
+
className="fdy-input"
|
|
287
|
+
type="search"
|
|
288
|
+
value={query}
|
|
289
|
+
placeholder="Cari…"
|
|
290
|
+
aria-label="Cari data"
|
|
291
|
+
aria-controls={controlsId}
|
|
292
|
+
aria-activedescendant={activeDescendant}
|
|
293
|
+
onChange={onSearchInput}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div className="fdy-cfl__results">
|
|
299
|
+
{isInitialLoading ? (
|
|
300
|
+
<p className="fdy-cfl__empty" role="status">Memuat…</p>
|
|
301
|
+
) : isBlockingError ? (
|
|
302
|
+
<div className="fdy-cfl__empty" role="alert">
|
|
303
|
+
<p style={{ margin: '0 0 var(--space-3)' }}>{error?.message}</p>
|
|
304
|
+
<button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>Coba lagi</button>
|
|
305
|
+
</div>
|
|
306
|
+
) : isEmpty ? (
|
|
307
|
+
<p className="fdy-cfl__empty">Tidak ada hasil.</p>
|
|
308
|
+
) : (
|
|
309
|
+
<>
|
|
310
|
+
<table id={resultsId} className="fdy-table" aria-label="Hasil pencarian">
|
|
311
|
+
<thead>
|
|
312
|
+
<tr>
|
|
313
|
+
{props.columns.map((col: CflColumn<Row>): JSX.Element => (
|
|
314
|
+
<th key={col.key} scope="col">{col.label}</th>
|
|
315
|
+
))}
|
|
316
|
+
</tr>
|
|
317
|
+
</thead>
|
|
318
|
+
<tbody>
|
|
319
|
+
{rows.map((row: Row, i: number): JSX.Element => (
|
|
320
|
+
<tr
|
|
321
|
+
id={rowId(i)}
|
|
322
|
+
key={props.rowKey(row)}
|
|
323
|
+
className="fdy-cfl__row"
|
|
324
|
+
aria-selected={i === activeIndex ? 'true' : undefined}
|
|
325
|
+
onClick={(): void => commit(row)}
|
|
326
|
+
onMouseMove={(): void => setActive(i)}
|
|
327
|
+
>
|
|
328
|
+
{props.columns.map((col: CflColumn<Row>): JSX.Element => (
|
|
329
|
+
<td key={col.key}>{cellText(row, col.key)}</td>
|
|
330
|
+
))}
|
|
331
|
+
</tr>
|
|
332
|
+
))}
|
|
333
|
+
</tbody>
|
|
334
|
+
</table>
|
|
335
|
+
|
|
336
|
+
{error !== null ? (
|
|
337
|
+
<div className="fdy-cfl__empty" role="alert" style={{ padding: 'var(--space-4) var(--space-5)' }}>
|
|
338
|
+
<p style={{ margin: '0 0 var(--space-3)' }}>{error.message}</p>
|
|
339
|
+
<button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>Coba lagi</button>
|
|
340
|
+
</div>
|
|
341
|
+
) : hasMore ? (
|
|
342
|
+
<div style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'center' }}>
|
|
343
|
+
<button className="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" disabled={loading} onClick={loadMore}>
|
|
344
|
+
{loading ? 'Memuat…' : 'Muat lebih banyak'}
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
) : null}
|
|
348
|
+
</>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<div className="fdy-modal__footer">
|
|
354
|
+
<span className="fdy-cfl__count">Klik baris untuk memilih</span>
|
|
355
|
+
<div className="fdy-cfl__actions">
|
|
356
|
+
<button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>Tutup</button>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</dialog>
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode, type JSX } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface FdyChartSeries {
|
|
4
|
+
label: string;
|
|
5
|
+
values: number[];
|
|
6
|
+
role?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FdyChartProps {
|
|
10
|
+
type: 'line' | 'area' | 'bar' | 'sparkline' | 'donut';
|
|
11
|
+
series?: ReadonlyArray<FdyChartSeries>;
|
|
12
|
+
values?: ReadonlyArray<number>;
|
|
13
|
+
labels?: ReadonlyArray<string>;
|
|
14
|
+
format?: 'number' | 'percent' | 'currency';
|
|
15
|
+
stacked?: boolean;
|
|
16
|
+
legend?: 'auto' | 'always' | 'none';
|
|
17
|
+
colors?: ReadonlyArray<string>;
|
|
18
|
+
color?: string;
|
|
19
|
+
center?: string | number;
|
|
20
|
+
'aria-label'?: string;
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FreedayChartApi { update: (el: HTMLElement) => void; }
|
|
25
|
+
function chartApi(): FreedayChartApi | null {
|
|
26
|
+
const api: FreedayChartApi | undefined = (window as unknown as { FreedayChart?: FreedayChartApi }).FreedayChart;
|
|
27
|
+
return api !== undefined && typeof api.update === 'function' ? api : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FdyChart(props: FdyChartProps): JSX.Element {
|
|
31
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
|
|
33
|
+
const isCartesian: boolean =
|
|
34
|
+
props.type === 'line' || props.type === 'area' || (props.type === 'bar' && (props.series !== undefined || props.stacked === true));
|
|
35
|
+
const rootClass: string =
|
|
36
|
+
props.type === 'sparkline' ? 'fdy-sparkline'
|
|
37
|
+
: props.type === 'donut' ? 'fdy-donut'
|
|
38
|
+
: props.type === 'bar' && !isCartesian ? 'fdy-bars'
|
|
39
|
+
: '';
|
|
40
|
+
|
|
41
|
+
useEffect((): void => {
|
|
42
|
+
const el: HTMLDivElement | null = rootRef.current;
|
|
43
|
+
const api: FreedayChartApi | null = chartApi();
|
|
44
|
+
if (el === null || api === null) return;
|
|
45
|
+
api.update(el);
|
|
46
|
+
el.dataset.fdyChartReady = '1'; // claim it so the global auto-init won't render it again
|
|
47
|
+
}, [props.type, props.series, props.values, props.labels, props.format, props.stacked, props.legend, props.colors, props.color, props.center]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
ref={rootRef}
|
|
52
|
+
className={rootClass}
|
|
53
|
+
data-fdy-chart={props.type}
|
|
54
|
+
data-series={props.series !== undefined ? JSON.stringify(props.series) : undefined}
|
|
55
|
+
data-values={props.values !== undefined ? props.values.join(',') : undefined}
|
|
56
|
+
data-labels={props.labels !== undefined ? props.labels.join(',') : undefined}
|
|
57
|
+
data-fdy-format={props.format ?? undefined}
|
|
58
|
+
data-fdy-stacked={props.stacked === true ? '' : undefined}
|
|
59
|
+
data-fdy-legend={props.legend ?? undefined}
|
|
60
|
+
data-fdy-colors={props.colors !== undefined ? props.colors.join(',') : undefined}
|
|
61
|
+
data-fdy-color={props.color ?? undefined}
|
|
62
|
+
data-fdy-center={props.center !== undefined ? String(props.center) : undefined}
|
|
63
|
+
aria-label={props['aria-label']}
|
|
64
|
+
role="img"
|
|
65
|
+
>
|
|
66
|
+
{props.children}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { usePopover } from '../usePopover';
|
|
4
|
+
|
|
5
|
+
export interface FdyComboOption<T extends string> {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FdyComboProps<T extends string> {
|
|
11
|
+
value: T;
|
|
12
|
+
options: ReadonlyArray<FdyComboOption<T>>;
|
|
13
|
+
onChange: (value: T) => void;
|
|
14
|
+
id?: string;
|
|
15
|
+
ariaLabelledby?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
describedby?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element {
|
|
23
|
+
const baseId: string = useId();
|
|
24
|
+
const buttonId: string = props.id ?? `${baseId}-btn`;
|
|
25
|
+
const listboxId: string = `${baseId}-listbox`;
|
|
26
|
+
const optionId = (index: number): string => `${baseId}-opt-${index}`;
|
|
27
|
+
|
|
28
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
30
|
+
const listboxRef = useRef<HTMLUListElement>(null);
|
|
31
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
32
|
+
const [highlighted, setHighlighted] = useState<number>(-1);
|
|
33
|
+
|
|
34
|
+
usePopover(listboxRef, buttonRef, open);
|
|
35
|
+
|
|
36
|
+
// Popover attr for React 18 (JSX may not type it): set once on mount.
|
|
37
|
+
useEffect((): void => {
|
|
38
|
+
listboxRef.current?.setAttribute('popover', 'manual');
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const isDisabled: boolean = props.disabled === true;
|
|
42
|
+
const isInvalid: boolean = props.invalid === true;
|
|
43
|
+
const selectedIndex: number = useMemo(
|
|
44
|
+
(): number => props.options.findIndex((o: FdyComboOption<T>): boolean => o.value === props.value),
|
|
45
|
+
[props.options, props.value],
|
|
46
|
+
);
|
|
47
|
+
const selectedLabel: string = selectedIndex >= 0 ? props.options[selectedIndex].label : (props.placeholder ?? '');
|
|
48
|
+
const isPlaceholder: boolean = selectedIndex < 0;
|
|
49
|
+
const activeDescendant: string | undefined = open && highlighted >= 0 ? optionId(highlighted) : undefined;
|
|
50
|
+
|
|
51
|
+
const setHighlight = (index: number): void => {
|
|
52
|
+
const len: number = props.options.length;
|
|
53
|
+
setHighlighted(len === 0 ? -1 : ((index % len) + len) % len);
|
|
54
|
+
};
|
|
55
|
+
const openList = (): void => {
|
|
56
|
+
if (isDisabled || open) return;
|
|
57
|
+
setOpen(true);
|
|
58
|
+
const start: number = selectedIndex >= 0 ? selectedIndex : 0;
|
|
59
|
+
const len: number = props.options.length;
|
|
60
|
+
setHighlighted(len === 0 ? -1 : ((start % len) + len) % len);
|
|
61
|
+
};
|
|
62
|
+
const closeList = (focusButton: boolean): void => {
|
|
63
|
+
setOpen(false);
|
|
64
|
+
setHighlighted(-1);
|
|
65
|
+
if (focusButton) buttonRef.current?.focus();
|
|
66
|
+
};
|
|
67
|
+
const toggle = (): void => { if (open) closeList(false); else openList(); };
|
|
68
|
+
const choose = (index: number): void => {
|
|
69
|
+
const opt: FdyComboOption<T> | undefined = props.options[index];
|
|
70
|
+
if (opt === undefined) return;
|
|
71
|
+
if (opt.value !== props.value) props.onChange(opt.value);
|
|
72
|
+
closeList(true);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Type-to-select: buffer keystrokes for 500ms and jump to the first matching label.
|
|
76
|
+
const typedRef = useRef<string>('');
|
|
77
|
+
const typedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
78
|
+
const typeahead = (char: string): void => {
|
|
79
|
+
typedRef.current += char.toLowerCase();
|
|
80
|
+
if (typedTimer.current !== null) clearTimeout(typedTimer.current);
|
|
81
|
+
typedTimer.current = setTimeout((): void => { typedRef.current = ''; }, 500);
|
|
82
|
+
const match: number = props.options.findIndex((o: FdyComboOption<T>): boolean =>
|
|
83
|
+
o.label.toLowerCase().startsWith(typedRef.current));
|
|
84
|
+
if (match >= 0) { if (!open) setOpen(true); setHighlight(match); }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
88
|
+
if (isDisabled) return;
|
|
89
|
+
switch (e.key) {
|
|
90
|
+
case 'ArrowDown': e.preventDefault(); if (open) setHighlight(highlighted + 1); else openList(); break;
|
|
91
|
+
case 'ArrowUp': e.preventDefault(); if (open) setHighlight(highlighted - 1); else openList(); break;
|
|
92
|
+
case 'Home': if (open) { e.preventDefault(); setHighlight(0); } break;
|
|
93
|
+
case 'End': if (open) { e.preventDefault(); setHighlight(props.options.length - 1); } break;
|
|
94
|
+
case 'Enter':
|
|
95
|
+
case ' ': e.preventDefault(); if (open && highlighted >= 0) choose(highlighted); else openList(); break;
|
|
96
|
+
case 'Escape': if (open) { e.preventDefault(); closeList(true); } break;
|
|
97
|
+
case 'Tab': if (open) closeList(false); break;
|
|
98
|
+
default:
|
|
99
|
+
if (e.key.length === 1 && /\S/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) typeahead(e.key);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
|
|
104
|
+
const next: EventTarget | null = e.relatedTarget;
|
|
105
|
+
if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closeList(false);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Close when a pointer lands outside the whole combo.
|
|
109
|
+
useEffect((): void | (() => void) => {
|
|
110
|
+
if (!open) return;
|
|
111
|
+
const onDocPointerDown = (e: MouseEvent): void => {
|
|
112
|
+
const t: EventTarget | null = e.target;
|
|
113
|
+
if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closeList(false);
|
|
114
|
+
};
|
|
115
|
+
document.addEventListener('mousedown', onDocPointerDown);
|
|
116
|
+
return (): void => document.removeEventListener('mousedown', onDocPointerDown);
|
|
117
|
+
}, [open]);
|
|
118
|
+
|
|
119
|
+
useEffect((): (() => void) => (): void => {
|
|
120
|
+
if (typedTimer.current !== null) clearTimeout(typedTimer.current);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
ref={rootRef}
|
|
126
|
+
className={isInvalid ? 'fdy-combo fdy-combo--error' : 'fdy-combo'}
|
|
127
|
+
data-value={props.value}
|
|
128
|
+
onKeyDown={onKeyDown}
|
|
129
|
+
onBlur={onFocusout}
|
|
130
|
+
>
|
|
131
|
+
<button
|
|
132
|
+
id={buttonId}
|
|
133
|
+
ref={buttonRef}
|
|
134
|
+
type="button"
|
|
135
|
+
className={open ? 'fdy-combo__button is-open' : 'fdy-combo__button'}
|
|
136
|
+
role="combobox"
|
|
137
|
+
aria-haspopup="listbox"
|
|
138
|
+
aria-controls={listboxId}
|
|
139
|
+
aria-expanded={open}
|
|
140
|
+
aria-activedescendant={activeDescendant}
|
|
141
|
+
aria-labelledby={props.ariaLabelledby}
|
|
142
|
+
aria-invalid={isInvalid ? 'true' : undefined}
|
|
143
|
+
aria-describedby={props.describedby}
|
|
144
|
+
disabled={isDisabled}
|
|
145
|
+
onClick={toggle}
|
|
146
|
+
>
|
|
147
|
+
<span className={isPlaceholder ? 'fdy-combo__value fdy-combo__value--placeholder' : 'fdy-combo__value'}>{selectedLabel}</span>
|
|
148
|
+
</button>
|
|
149
|
+
<ul id={listboxId} ref={listboxRef} className="fdy-combo__listbox" role="listbox" hidden={!open}>
|
|
150
|
+
{props.options.map((opt: FdyComboOption<T>, i: number): JSX.Element => (
|
|
151
|
+
<li
|
|
152
|
+
id={optionId(i)}
|
|
153
|
+
key={opt.value}
|
|
154
|
+
className={i === highlighted ? 'fdy-combo__option is-highlighted' : 'fdy-combo__option'}
|
|
155
|
+
role="option"
|
|
156
|
+
aria-selected={opt.value === props.value}
|
|
157
|
+
onClick={(): void => choose(i)}
|
|
158
|
+
onMouseMove={(): void => setHighlight(i)}
|
|
159
|
+
>
|
|
160
|
+
<span className="fdy-combo__check">{opt.value === props.value ? '✓' : ''}</span>{opt.label}
|
|
161
|
+
</li>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { FdyDatepicker } from './FdyDatepicker';
|
|
3
|
+
|
|
4
|
+
// A controlled date range over freeday's `.fdy-daterange` layout (src/components/datepicker.css):
|
|
5
|
+
// two linked FdyDatepickers where the end can't precede the start (start.max = end, end.min = start).
|
|
6
|
+
// `value` + `onChange` in place of Vue's `v-model`; this composes the single-date picker rather than
|
|
7
|
+
// re-implementing the calendar. Note: the vanilla enhancer's cross-calendar `.in-range` day shading
|
|
8
|
+
// is not reproduced (each picker is independent) — the min/max linkage is what keeps the range valid.
|
|
9
|
+
|
|
10
|
+
export interface DateRangeValue {
|
|
11
|
+
start: string | null;
|
|
12
|
+
end: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FdyDateRangeProps {
|
|
16
|
+
value: DateRangeValue;
|
|
17
|
+
onChange: (value: DateRangeValue) => void;
|
|
18
|
+
min?: string;
|
|
19
|
+
max?: string;
|
|
20
|
+
locale?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
invalid?: boolean;
|
|
23
|
+
describedby?: string;
|
|
24
|
+
startPlaceholder?: string;
|
|
25
|
+
endPlaceholder?: string;
|
|
26
|
+
ariaLabel?: string;
|
|
27
|
+
ariaLabelledby?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
|
|
31
|
+
const onStart = (start: string): void => props.onChange({ start, end: props.value.end });
|
|
32
|
+
const onEnd = (end: string): void => props.onChange({ start: props.value.start, end });
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className="fdy-daterange"
|
|
37
|
+
role="group"
|
|
38
|
+
aria-label={props.ariaLabel}
|
|
39
|
+
aria-labelledby={props.ariaLabelledby}
|
|
40
|
+
>
|
|
41
|
+
<FdyDatepicker
|
|
42
|
+
value={props.value.start}
|
|
43
|
+
onChange={onStart}
|
|
44
|
+
min={props.min}
|
|
45
|
+
max={props.value.end ?? props.max}
|
|
46
|
+
locale={props.locale}
|
|
47
|
+
disabled={props.disabled}
|
|
48
|
+
invalid={props.invalid}
|
|
49
|
+
describedby={props.describedby}
|
|
50
|
+
placeholder={props.startPlaceholder ?? 'Dari'}
|
|
51
|
+
/>
|
|
52
|
+
<span className="fdy-daterange__sep" aria-hidden="true">–</span>
|
|
53
|
+
<FdyDatepicker
|
|
54
|
+
value={props.value.end}
|
|
55
|
+
onChange={onEnd}
|
|
56
|
+
min={props.value.start ?? props.min}
|
|
57
|
+
max={props.max}
|
|
58
|
+
locale={props.locale}
|
|
59
|
+
disabled={props.disabled}
|
|
60
|
+
invalid={props.invalid}
|
|
61
|
+
describedby={props.describedby}
|
|
62
|
+
placeholder={props.endPlaceholder ?? 'Sampai'}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|