@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/adapters/blazor/freeday-blazor.js +79 -0
  5. package/adapters/react/components/FdyAutocomplete.tsx +159 -0
  6. package/adapters/react/components/FdyCascade.tsx +230 -0
  7. package/adapters/react/components/FdyCfl.tsx +362 -0
  8. package/adapters/react/components/FdyChart.tsx +69 -0
  9. package/adapters/react/components/FdyCombo.tsx +166 -0
  10. package/adapters/react/components/FdyDateRange.tsx +66 -0
  11. package/adapters/react/components/FdyDatepicker.tsx +354 -0
  12. package/adapters/react/index.d.ts +37 -0
  13. package/adapters/react/index.js +9 -0
  14. package/adapters/react/tsconfig.json +14 -0
  15. package/adapters/react/useFreeday.js +44 -0
  16. package/adapters/react/usePopover.ts +64 -0
  17. package/adapters/vue/components/FdyAutocomplete.vue +174 -0
  18. package/adapters/vue/components/FdyCascade.vue +241 -0
  19. package/adapters/vue/components/FdyCfl.vue +354 -0
  20. package/adapters/vue/components/FdyChart.vue +89 -0
  21. package/adapters/vue/components/FdyCombo.vue +232 -0
  22. package/adapters/vue/components/FdyDateRange.vue +78 -0
  23. package/adapters/vue/components/FdyDatepicker.vue +333 -0
  24. package/adapters/vue/index.d.ts +44 -0
  25. package/adapters/vue/index.js +8 -0
  26. package/adapters/vue/useFreeday.js +44 -0
  27. package/adapters/vue/usePopover.ts +78 -0
  28. package/dist/freeday-autocomplete.js +135 -0
  29. package/dist/freeday-breakpoint.js +51 -0
  30. package/dist/freeday-carousel.js +111 -0
  31. package/dist/freeday-cascade.js +256 -0
  32. package/dist/freeday-cfl.js +213 -0
  33. package/dist/freeday-chart.js +403 -0
  34. package/dist/freeday-chip.js +83 -0
  35. package/dist/freeday-datepicker.js +321 -0
  36. package/dist/freeday-datetime.js +59 -0
  37. package/dist/freeday-drawer.js +43 -0
  38. package/dist/freeday-form.js +181 -0
  39. package/dist/freeday-mask.js +114 -0
  40. package/dist/freeday-menu.js +93 -0
  41. package/dist/freeday-popover.js +69 -0
  42. package/dist/freeday-rating.js +50 -0
  43. package/dist/freeday-select.js +194 -0
  44. package/dist/freeday-slider.js +34 -0
  45. package/dist/freeday-stepper.js +90 -0
  46. package/dist/freeday-table.js +475 -0
  47. package/dist/freeday-tabs.js +68 -0
  48. package/dist/freeday-timepicker.js +180 -0
  49. package/dist/freeday-toast.js +84 -0
  50. package/dist/freeday-tree.js +94 -0
  51. package/dist/freeday-upload.js +206 -0
  52. package/dist/freeday.bundle.css +1352 -0
  53. package/dist/freeday.css +1007 -0
  54. package/dist/freeday.js +3632 -0
  55. package/dist/freeday.tokens.css +343 -0
  56. package/package.json +72 -0
  57. package/src/base.css +20 -0
  58. package/src/components/.gitkeep +0 -0
  59. package/src/components/accordion.css +13 -0
  60. package/src/components/alert.css +18 -0
  61. package/src/components/app-shell.css +88 -0
  62. package/src/components/appbar.css +15 -0
  63. package/src/components/autocomplete.css +12 -0
  64. package/src/components/avatar.css +8 -0
  65. package/src/components/badge.css +27 -0
  66. package/src/components/breadcrumb.css +9 -0
  67. package/src/components/breakpoints.css +15 -0
  68. package/src/components/button.css +58 -0
  69. package/src/components/card.css +9 -0
  70. package/src/components/carousel.css +17 -0
  71. package/src/components/cascade.css +36 -0
  72. package/src/components/cfl.css +28 -0
  73. package/src/components/chart.css +55 -0
  74. package/src/components/chip.css +18 -0
  75. package/src/components/combo.css +27 -0
  76. package/src/components/datepicker.css +44 -0
  77. package/src/components/datetimepicker.css +8 -0
  78. package/src/components/description-list.css +6 -0
  79. package/src/components/divider.css +3 -0
  80. package/src/components/drawer.css +25 -0
  81. package/src/components/file-upload.css +34 -0
  82. package/src/components/filterbar.css +25 -0
  83. package/src/components/form-grid.css +8 -0
  84. package/src/components/input-group.css +22 -0
  85. package/src/components/input.css +13 -0
  86. package/src/components/kbd.css +2 -0
  87. package/src/components/menu.css +28 -0
  88. package/src/components/modal.css +22 -0
  89. package/src/components/pagination.css +8 -0
  90. package/src/components/progress.css +7 -0
  91. package/src/components/rating.css +10 -0
  92. package/src/components/selection.css +25 -0
  93. package/src/components/skeleton.css +7 -0
  94. package/src/components/slider.css +12 -0
  95. package/src/components/spinner.css +6 -0
  96. package/src/components/states.css +8 -0
  97. package/src/components/stepper.css +21 -0
  98. package/src/components/table.css +55 -0
  99. package/src/components/tabs.css +10 -0
  100. package/src/components/timeline.css +12 -0
  101. package/src/components/timepicker.css +22 -0
  102. package/src/components/toast.css +17 -0
  103. package/src/components/tooltip.css +6 -0
  104. package/src/components/tree.css +21 -0
  105. package/src/freeday-autocomplete.js +135 -0
  106. package/src/freeday-breakpoint.js +51 -0
  107. package/src/freeday-carousel.js +111 -0
  108. package/src/freeday-cascade.js +256 -0
  109. package/src/freeday-cfl.js +213 -0
  110. package/src/freeday-chart.js +403 -0
  111. package/src/freeday-chip.js +83 -0
  112. package/src/freeday-datepicker.js +321 -0
  113. package/src/freeday-datetime.js +59 -0
  114. package/src/freeday-drawer.js +43 -0
  115. package/src/freeday-form.js +181 -0
  116. package/src/freeday-mask.js +114 -0
  117. package/src/freeday-menu.js +93 -0
  118. package/src/freeday-popover.js +69 -0
  119. package/src/freeday-rating.js +50 -0
  120. package/src/freeday-select.js +194 -0
  121. package/src/freeday-slider.js +34 -0
  122. package/src/freeday-stepper.js +90 -0
  123. package/src/freeday-table.js +475 -0
  124. package/src/freeday-tabs.js +68 -0
  125. package/src/freeday-timepicker.js +180 -0
  126. package/src/freeday-toast.js +84 -0
  127. package/src/freeday-tree.js +94 -0
  128. package/src/freeday-upload.js +206 -0
  129. package/tokens/breakpoints.mjs +4 -0
  130. 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}>&times;</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
+ }