@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,354 @@
1
+ <script setup lang="ts" generic="Row extends Record<string, unknown>">
2
+ import { computed, nextTick, onBeforeUnmount, ref, useId, type ComputedRef, type Ref } from 'vue';
3
+
4
+ // A Vue-native, *controlled async* choose-from-list over freeday's `.fdy-cfl*` +
5
+ // `.fdy-input-group` classes (see src/components/cfl.css, input-group.css). freeday
6
+ // ships a framework-agnostic enhancer (freeday-cfl.js) that scans static `.fdy-cfl__row`
7
+ // markup; its own header says a framework app should "drive the dialog as a controlled
8
+ // component (fetchPage callback + server cache), not a store." This component does exactly
9
+ // that: a real `v-model:Row|null` over a native <dialog> whose rows come from an async
10
+ // `fetchPage(query, page)`. It re-implements the enhancer's open/close + search + dense
11
+ // sticky-header results + single-commit-on-click + keyboard, and adds the pieces a server
12
+ // picker needs — loading/empty/error states, retry, pagination, an out-of-order guard, and
13
+ // an optional in-memory cache. The enhancer is NOT mounted here (it would fight Vue's DOM).
14
+
15
+ interface CflColumn {
16
+ key: keyof Row & string;
17
+ label: string;
18
+ }
19
+
20
+ interface CflPage {
21
+ rows: Row[];
22
+ hasMore: boolean;
23
+ }
24
+
25
+ const props = defineProps<{
26
+ modelValue: Row | null;
27
+ fetchPage: (query: string, page: number) => Promise<CflPage>;
28
+ columns: ReadonlyArray<CflColumn>;
29
+ display: (row: Row) => string;
30
+ rowKey: (row: Row) => string;
31
+ /** Advisory only — the caller's `fetchPage` owns paging; kept for API documentation. */
32
+ pageSize?: number;
33
+ placeholder?: string;
34
+ disabled?: boolean;
35
+ invalid?: boolean;
36
+ describedby?: string;
37
+ id?: string;
38
+ ariaLabelledby?: string;
39
+ }>();
40
+
41
+ const emit = defineEmits<{
42
+ 'update:modelValue': [value: Row];
43
+ change: [value: Row];
44
+ }>();
45
+
46
+ const baseId: string = useId();
47
+ const fieldId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-field`);
48
+ const titleId: string = `${baseId}-title`;
49
+ const resultsId: string = `${baseId}-results`;
50
+ function rowId(index: number): string {
51
+ return `${baseId}-row-${index}`;
52
+ }
53
+
54
+ const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
55
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
56
+ const searchEl: Ref<HTMLInputElement | null> = ref(null);
57
+
58
+ const query: Ref<string> = ref('');
59
+ const rows: Ref<Row[]> = ref([]) as Ref<Row[]>;
60
+ const page: Ref<number> = ref(0);
61
+ const hasMore: Ref<boolean> = ref(false);
62
+ const loading: Ref<boolean> = ref(false);
63
+ const error: Ref<Error | null> = ref(null);
64
+ const activeIndex: Ref<number> = ref(-1);
65
+
66
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
67
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
68
+ const displayValue: ComputedRef<string> = computed((): string =>
69
+ props.modelValue !== null ? props.display(props.modelValue) : '',
70
+ );
71
+ // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
72
+ // search input's aria refs on rows existing — otherwise they'd dangle during loading/empty/error.
73
+ const hasRows: ComputedRef<boolean> = computed((): boolean => rows.value.length > 0);
74
+ const controlsId: ComputedRef<string | undefined> = computed((): string | undefined =>
75
+ hasRows.value ? resultsId : undefined,
76
+ );
77
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
78
+ hasRows.value && activeIndex.value >= 0 ? rowId(activeIndex.value) : undefined,
79
+ );
80
+ const isInitialLoading: ComputedRef<boolean> = computed((): boolean => loading.value && rows.value.length === 0);
81
+ const isBlockingError: ComputedRef<boolean> = computed((): boolean => error.value !== null && rows.value.length === 0);
82
+ const isEmpty: ComputedRef<boolean> = computed(
83
+ (): boolean => !loading.value && error.value === null && rows.value.length === 0,
84
+ );
85
+
86
+ // --- Async engine ---------------------------------------------------------
87
+ // Out-of-order guard: every fetch takes a monotonically increasing token; a
88
+ // resolved/rejected page is discarded unless it still owns the latest token
89
+ // (a newer search, a load-more, or a dialog reset all bump it). This keeps a
90
+ // slow stale response from overwriting fresh results.
91
+ let reqToken: number = 0;
92
+ // Optional in-memory cache, keyed by `${query}::${page}`, cleared on close.
93
+ const cache: Map<string, CflPage> = new Map<string, CflPage>();
94
+ let lastPage: number = 0;
95
+ let lastAppend: boolean = false;
96
+ let searchTimer: ReturnType<typeof setTimeout> | null = null;
97
+
98
+ async function loadPage(targetPage: number, append: boolean): Promise<void> {
99
+ const q: string = query.value;
100
+ lastPage = targetPage;
101
+ lastAppend = append;
102
+ const token: number = ++reqToken;
103
+ const key = `${q}::${targetPage}`;
104
+ error.value = null;
105
+ loading.value = true;
106
+ try {
107
+ const cached: CflPage | undefined = cache.get(key);
108
+ const res: CflPage = cached ?? (await props.fetchPage(q, targetPage));
109
+ if (token !== reqToken) return; // stale — a newer request has started
110
+ if (cached === undefined) cache.set(key, res);
111
+ const copy: Row[] = res.rows.slice(); // never mutate the caller's array
112
+ rows.value = append ? rows.value.concat(copy) : copy;
113
+ hasMore.value = res.hasMore;
114
+ page.value = targetPage;
115
+ if (!append) activeIndex.value = rows.value.length > 0 ? 0 : -1;
116
+ } catch (err: unknown) {
117
+ if (token !== reqToken) return;
118
+ error.value = err instanceof Error ? err : new Error(String(err));
119
+ } finally {
120
+ if (token === reqToken) loading.value = false;
121
+ }
122
+ }
123
+
124
+ function retry(): void {
125
+ void loadPage(lastPage, lastAppend);
126
+ }
127
+
128
+ function loadMore(): void {
129
+ if (loading.value || !hasMore.value) return;
130
+ void loadPage(page.value + 1, true);
131
+ }
132
+
133
+ function onSearchInput(e: Event): void {
134
+ query.value = (e.target as HTMLInputElement).value;
135
+ if (searchTimer !== null) clearTimeout(searchTimer);
136
+ searchTimer = setTimeout((): void => {
137
+ void loadPage(0, false);
138
+ }, 250);
139
+ }
140
+
141
+ // --- Active-row / keyboard ------------------------------------------------
142
+ function setActive(index: number): void {
143
+ if (rows.value.length === 0) {
144
+ activeIndex.value = -1;
145
+ return;
146
+ }
147
+ const clamped: number = Math.max(0, Math.min(index, rows.value.length - 1));
148
+ activeIndex.value = clamped;
149
+ void nextTick((): void => {
150
+ document.getElementById(rowId(clamped))?.scrollIntoView({ block: 'nearest' });
151
+ });
152
+ }
153
+
154
+ function commit(row: Row): void {
155
+ emit('update:modelValue', row);
156
+ emit('change', row);
157
+ closeDialog();
158
+ }
159
+
160
+ function onKeydown(e: KeyboardEvent): void {
161
+ switch (e.key) {
162
+ case 'ArrowDown':
163
+ e.preventDefault();
164
+ setActive(activeIndex.value + 1);
165
+ break;
166
+ case 'ArrowUp':
167
+ e.preventDefault();
168
+ setActive(activeIndex.value - 1);
169
+ break;
170
+ case 'Home':
171
+ if (rows.value.length > 0) {
172
+ e.preventDefault();
173
+ setActive(0);
174
+ }
175
+ break;
176
+ case 'End':
177
+ if (rows.value.length > 0) {
178
+ e.preventDefault();
179
+ setActive(rows.value.length - 1);
180
+ }
181
+ break;
182
+ case 'Enter': {
183
+ const row: Row | undefined = activeIndex.value >= 0 ? rows.value[activeIndex.value] : undefined;
184
+ if (row !== undefined) {
185
+ e.preventDefault();
186
+ commit(row);
187
+ }
188
+ break;
189
+ }
190
+ default:
191
+ break; // Escape is left to the native <dialog> cancel/close
192
+ }
193
+ }
194
+
195
+ // --- Open / close ---------------------------------------------------------
196
+ function openDialog(): void {
197
+ if (isDisabled.value) return;
198
+ if (searchTimer !== null) {
199
+ clearTimeout(searchTimer);
200
+ searchTimer = null;
201
+ }
202
+ cache.clear();
203
+ query.value = '';
204
+ rows.value = [];
205
+ page.value = 0;
206
+ hasMore.value = false;
207
+ error.value = null;
208
+ activeIndex.value = -1;
209
+ dialogEl.value?.showModal();
210
+ void nextTick((): void => searchEl.value?.focus());
211
+ void loadPage(0, false);
212
+ }
213
+
214
+ function closeDialog(): void {
215
+ dialogEl.value?.close();
216
+ }
217
+
218
+ // Fires for every close path (button, Esc, commit); centralise cleanup here.
219
+ function onClose(): void {
220
+ reqToken++; // invalidate any in-flight fetch so it can't apply after close
221
+ if (searchTimer !== null) {
222
+ // Cancel a debounce armed just before close (e.g. typed a char then clicked a
223
+ // row) so it can't fire a stray fetchPage after the dialog is gone.
224
+ clearTimeout(searchTimer);
225
+ searchTimer = null;
226
+ }
227
+ cache.clear();
228
+ loading.value = false;
229
+ triggerEl.value?.focus();
230
+ }
231
+
232
+ function cellText(row: Row, key: keyof Row & string): string {
233
+ const value: unknown = row[key];
234
+ return value === null || value === undefined ? '' : String(value);
235
+ }
236
+
237
+ onBeforeUnmount((): void => {
238
+ if (searchTimer !== null) clearTimeout(searchTimer);
239
+ });
240
+ </script>
241
+
242
+ <template>
243
+ <div class="fdy-input-group">
244
+ <input
245
+ :id="fieldId"
246
+ class="fdy-input"
247
+ type="text"
248
+ readonly
249
+ :value="displayValue"
250
+ :placeholder="placeholder"
251
+ :aria-labelledby="ariaLabelledby"
252
+ :aria-invalid="isInvalid ? 'true' : undefined"
253
+ :aria-describedby="describedby"
254
+ :disabled="isDisabled"
255
+ />
256
+ <button
257
+ ref="triggerEl"
258
+ type="button"
259
+ class="fdy-input-group__btn"
260
+ aria-haspopup="dialog"
261
+ :aria-labelledby="ariaLabelledby"
262
+ :aria-label="ariaLabelledby ? undefined : 'Buka pencarian'"
263
+ :disabled="isDisabled"
264
+ @click="openDialog"
265
+ >
266
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
267
+ <circle cx="11" cy="11" r="7"></circle>
268
+ <path d="m21 21-4.35-4.35"></path>
269
+ </svg>
270
+ </button>
271
+
272
+ <dialog ref="dialogEl" class="fdy-modal fdy-modal--cfl" :aria-labelledby="titleId" @close="onClose" @keydown="onKeydown">
273
+ <div class="fdy-modal__header">
274
+ <h3 :id="titleId" class="fdy-modal__title">Pilih data</h3>
275
+ <button class="fdy-modal__close" type="button" aria-label="Tutup" @click="closeDialog">&times;</button>
276
+ </div>
277
+
278
+ <div class="fdy-modal__body">
279
+ <div class="fdy-cfl__search">
280
+ <div class="fdy-input-group" style="max-width:none">
281
+ <span class="fdy-input-group__addon fdy-input-group__addon--icon">
282
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
283
+ <circle cx="11" cy="11" r="7"></circle>
284
+ <path d="m21 21-4.35-4.35"></path>
285
+ </svg>
286
+ </span>
287
+ <input
288
+ ref="searchEl"
289
+ class="fdy-input"
290
+ type="search"
291
+ :value="query"
292
+ placeholder="Cari…"
293
+ aria-label="Cari data"
294
+ :aria-controls="controlsId"
295
+ :aria-activedescendant="activeDescendant"
296
+ @input="onSearchInput"
297
+ />
298
+ </div>
299
+ </div>
300
+
301
+ <div class="fdy-cfl__results">
302
+ <p v-if="isInitialLoading" class="fdy-cfl__empty" role="status">Memuat…</p>
303
+
304
+ <div v-else-if="isBlockingError" class="fdy-cfl__empty" role="alert">
305
+ <p style="margin:0 0 var(--space-3)">{{ error?.message }}</p>
306
+ <button class="fdy-btn fdy-btn--sm" type="button" @click="retry">Coba lagi</button>
307
+ </div>
308
+
309
+ <p v-else-if="isEmpty" class="fdy-cfl__empty">Tidak ada hasil.</p>
310
+
311
+ <template v-else>
312
+ <table :id="resultsId" class="fdy-table" aria-label="Hasil pencarian">
313
+ <thead>
314
+ <tr>
315
+ <th v-for="col in columns" :key="col.key" scope="col">{{ col.label }}</th>
316
+ </tr>
317
+ </thead>
318
+ <tbody>
319
+ <tr
320
+ v-for="(row, i) in rows"
321
+ :id="rowId(i)"
322
+ :key="rowKey(row)"
323
+ class="fdy-cfl__row"
324
+ :aria-selected="i === activeIndex ? 'true' : undefined"
325
+ @click="commit(row)"
326
+ @mousemove="setActive(i)"
327
+ >
328
+ <td v-for="col in columns" :key="col.key">{{ cellText(row, col.key) }}</td>
329
+ </tr>
330
+ </tbody>
331
+ </table>
332
+
333
+ <div v-if="error !== null" class="fdy-cfl__empty" role="alert" style="padding:var(--space-4) var(--space-5)">
334
+ <p style="margin:0 0 var(--space-3)">{{ error.message }}</p>
335
+ <button class="fdy-btn fdy-btn--sm" type="button" @click="retry">Coba lagi</button>
336
+ </div>
337
+ <div v-else-if="hasMore" style="padding:var(--space-3) var(--space-4);text-align:center">
338
+ <button class="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" :disabled="loading" @click="loadMore">
339
+ {{ loading ? 'Memuat…' : 'Muat lebih banyak' }}
340
+ </button>
341
+ </div>
342
+ </template>
343
+ </div>
344
+ </div>
345
+
346
+ <div class="fdy-modal__footer">
347
+ <span class="fdy-cfl__count">Klik baris untuk memilih</span>
348
+ <div class="fdy-cfl__actions">
349
+ <button class="fdy-btn fdy-btn--ghost" type="button" @click="closeDialog">Tutup</button>
350
+ </div>
351
+ </div>
352
+ </dialog>
353
+ </div>
354
+ </template>
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick, type ComputedRef, type Ref } from 'vue';
3
+
4
+ // A Vue-native wrapper over freeday's zero-dependency chart renderer (freeday-chart.js).
5
+ // Unlike the input components, charts are not form controls, so re-implementing them in Vue
6
+ // would only duplicate the SVG maths. Instead this thin wrapper binds the renderer's data-*
7
+ // API reactively and re-invokes window.FreedayChart.update() whenever the data changes — giving
8
+ // reactive, typed charts (a drop-in replacement for hand-rolled Chart.js wrappers) with no
9
+ // external dependency. Because the renderer paints with CSS var()/--chart-* tokens, charts also
10
+ // recolour on data-theme change for free. Requires the freeday enhancers (dist/freeday.js) to be
11
+ // loaded so window.FreedayChart exists; until then the slotted fallback content stays visible.
12
+
13
+ interface FdyChartSeries {
14
+ label: string;
15
+ values: number[];
16
+ role?: string;
17
+ }
18
+
19
+ // Note: no `ariaLabel` prop — pass `aria-label` (and any other native attribute) directly; it
20
+ // falls through onto the root element. `role="img"` is set below. Give every chart an aria-label.
21
+ const props = defineProps<{
22
+ type: 'line' | 'area' | 'bar' | 'sparkline' | 'donut';
23
+ series?: ReadonlyArray<FdyChartSeries>;
24
+ values?: ReadonlyArray<number>;
25
+ labels?: ReadonlyArray<string>;
26
+ format?: 'number' | 'percent' | 'currency';
27
+ stacked?: boolean;
28
+ legend?: 'auto' | 'always' | 'none';
29
+ colors?: ReadonlyArray<string>;
30
+ color?: string;
31
+ center?: string | number;
32
+ }>();
33
+
34
+ interface FreedayChartApi { update: (el: HTMLElement) => void; }
35
+ function chartApi(): FreedayChartApi | null {
36
+ const api: FreedayChartApi | undefined = (window as unknown as { FreedayChart?: FreedayChartApi }).FreedayChart;
37
+ return api !== undefined && typeof api.update === 'function' ? api : null;
38
+ }
39
+
40
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
41
+
42
+ // Cartesian (axes) when line/area, or a bar with multiple series / stacking; those get their
43
+ // layout class from the renderer. sparkline/donut/simple-bar need their layout class up front.
44
+ const isCartesian: ComputedRef<boolean> = computed((): boolean =>
45
+ props.type === 'line' || props.type === 'area' || (props.type === 'bar' && (props.series !== undefined || props.stacked === true)),
46
+ );
47
+ const rootClass: ComputedRef<string> = computed((): string => {
48
+ if (props.type === 'sparkline') return 'fdy-sparkline';
49
+ if (props.type === 'donut') return 'fdy-donut';
50
+ if (props.type === 'bar' && !isCartesian.value) return 'fdy-bars';
51
+ return '';
52
+ });
53
+
54
+ function render(): void {
55
+ const el: HTMLDivElement | null = rootEl.value;
56
+ const api: FreedayChartApi | null = chartApi();
57
+ if (el === null || api === null) return;
58
+ api.update(el);
59
+ el.dataset.fdyChartReady = '1'; // claim it so the global auto-init won't render it again
60
+ }
61
+
62
+ onMounted((): void => { void nextTick(render); });
63
+ watch(
64
+ (): unknown[] => [props.type, props.series, props.values, props.labels, props.format, props.stacked, props.legend, props.colors, props.color, props.center],
65
+ (): void => { void nextTick(render); },
66
+ { deep: true },
67
+ );
68
+ onBeforeUnmount((): void => { const el: HTMLDivElement | null = rootEl.value; if (el !== null) el.innerHTML = ''; });
69
+ </script>
70
+
71
+ <template>
72
+ <div
73
+ ref="rootEl"
74
+ :class="rootClass"
75
+ :data-fdy-chart="type"
76
+ :data-series="series !== undefined ? JSON.stringify(series) : undefined"
77
+ :data-values="values !== undefined ? values.join(',') : undefined"
78
+ :data-labels="labels !== undefined ? labels.join(',') : undefined"
79
+ :data-fdy-format="format ?? undefined"
80
+ :data-fdy-stacked="stacked === true ? '' : undefined"
81
+ :data-fdy-legend="legend ?? undefined"
82
+ :data-fdy-colors="colors !== undefined ? colors.join(',') : undefined"
83
+ :data-fdy-color="color ?? undefined"
84
+ :data-fdy-center="center !== undefined ? String(center) : undefined"
85
+ role="img"
86
+ >
87
+ <slot />
88
+ </div>
89
+ </template>
@@ -0,0 +1,232 @@
1
+ <script setup lang="ts" generic="T extends string">
2
+ import { computed, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A Vue-native binding over freeday's `.fdy-combo` classes. freeday ships the styled
6
+ // listbox (combo.css) plus a framework-agnostic enhancer (freeday-select.js) whose own
7
+ // header says: "In a Vue app you would bind your own state instead." Letting that enhancer
8
+ // own the DOM would fight Vue's rendering, so this component re-implements the WAI-ARIA
9
+ // APG "select-only combobox" interaction in Vue, giving a real `v-model` and a themed
10
+ // popup that replaces the native <select> whose open list is an unthemeable OS menu.
11
+ // Generic over the value type so a union like RangePreset binds through v-model unchanged.
12
+
13
+ interface ComboOption {
14
+ value: T;
15
+ label: string;
16
+ }
17
+
18
+ const props = defineProps<{
19
+ modelValue: T;
20
+ options: ReadonlyArray<ComboOption>;
21
+ id?: string;
22
+ ariaLabelledby?: string;
23
+ placeholder?: string;
24
+ disabled?: boolean;
25
+ invalid?: boolean;
26
+ describedby?: string;
27
+ }>();
28
+
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: T];
31
+ change: [value: T];
32
+ }>();
33
+
34
+ const baseId: string = useId();
35
+ const buttonId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-btn`);
36
+ const listboxId: string = `${baseId}-listbox`;
37
+ function optionId(index: number): string {
38
+ return `${baseId}-opt-${index}`;
39
+ }
40
+
41
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
42
+ const buttonEl: Ref<HTMLButtonElement | null> = ref(null);
43
+ const listboxEl: Ref<HTMLUListElement | null> = ref(null);
44
+ const open: Ref<boolean> = ref(false);
45
+
46
+ // Render the listbox in the top layer (popover) so it escapes any ancestor overflow clip
47
+ // (a .fdy-card, a scroll container) and positions against the button.
48
+ usePopover(listboxEl, buttonEl, open);
49
+ const highlighted: Ref<number> = ref(-1);
50
+
51
+ const selectedIndex: ComputedRef<number> = computed((): number =>
52
+ props.options.findIndex((o: ComboOption): boolean => o.value === props.modelValue),
53
+ );
54
+ const selectedLabel: ComputedRef<string> = computed((): string => {
55
+ const opt: ComboOption | undefined = props.options[selectedIndex.value];
56
+ return opt !== undefined ? opt.label : (props.placeholder ?? '');
57
+ });
58
+ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => selectedIndex.value < 0);
59
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
60
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
61
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
62
+ open.value && highlighted.value >= 0 ? optionId(highlighted.value) : undefined,
63
+ );
64
+
65
+ function setHighlight(index: number): void {
66
+ const len: number = props.options.length;
67
+ highlighted.value = len === 0 ? -1 : ((index % len) + len) % len;
68
+ }
69
+
70
+ function openList(): void {
71
+ if (isDisabled.value || open.value) return;
72
+ open.value = true;
73
+ setHighlight(selectedIndex.value >= 0 ? selectedIndex.value : 0);
74
+ }
75
+
76
+ function closeList(focusButton: boolean): void {
77
+ if (!open.value) return;
78
+ open.value = false;
79
+ highlighted.value = -1;
80
+ if (focusButton) buttonEl.value?.focus();
81
+ }
82
+
83
+ function toggle(): void {
84
+ if (open.value) closeList(false);
85
+ else openList();
86
+ }
87
+
88
+ function choose(index: number): void {
89
+ const opt: ComboOption | undefined = props.options[index];
90
+ if (opt === undefined) return;
91
+ if (opt.value !== props.modelValue) {
92
+ emit('update:modelValue', opt.value);
93
+ emit('change', opt.value);
94
+ }
95
+ closeList(true);
96
+ }
97
+
98
+ // Type-to-select: buffer keystrokes for 500ms and jump to the first matching label.
99
+ let typed: string = '';
100
+ let typedTimer: ReturnType<typeof setTimeout> | null = null;
101
+ function typeahead(char: string): void {
102
+ typed += char.toLowerCase();
103
+ if (typedTimer !== null) clearTimeout(typedTimer);
104
+ typedTimer = setTimeout((): void => {
105
+ typed = '';
106
+ }, 500);
107
+ const match: number = props.options.findIndex((o: ComboOption): boolean =>
108
+ o.label.toLowerCase().startsWith(typed),
109
+ );
110
+ if (match >= 0) {
111
+ if (!open.value) openList();
112
+ setHighlight(match);
113
+ }
114
+ }
115
+
116
+ function onKeydown(e: KeyboardEvent): void {
117
+ if (isDisabled.value) return;
118
+ switch (e.key) {
119
+ case 'ArrowDown':
120
+ e.preventDefault();
121
+ if (open.value) setHighlight(highlighted.value + 1);
122
+ else openList();
123
+ break;
124
+ case 'ArrowUp':
125
+ e.preventDefault();
126
+ if (open.value) setHighlight(highlighted.value - 1);
127
+ else openList();
128
+ break;
129
+ case 'Home':
130
+ if (open.value) {
131
+ e.preventDefault();
132
+ setHighlight(0);
133
+ }
134
+ break;
135
+ case 'End':
136
+ if (open.value) {
137
+ e.preventDefault();
138
+ setHighlight(props.options.length - 1);
139
+ }
140
+ break;
141
+ case 'Enter':
142
+ case ' ':
143
+ e.preventDefault();
144
+ if (open.value && highlighted.value >= 0) choose(highlighted.value);
145
+ else openList();
146
+ break;
147
+ case 'Escape':
148
+ if (open.value) {
149
+ e.preventDefault();
150
+ closeList(true);
151
+ }
152
+ break;
153
+ case 'Tab':
154
+ if (open.value) closeList(false);
155
+ break;
156
+ default:
157
+ if (e.key.length === 1 && /\S/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
158
+ typeahead(e.key);
159
+ }
160
+ }
161
+ }
162
+
163
+ // Close when a pointer lands outside the whole combo.
164
+ function onDocPointerDown(e: MouseEvent): void {
165
+ const target: EventTarget | null = e.target;
166
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) {
167
+ closeList(false);
168
+ }
169
+ }
170
+
171
+ // Close when focus leaves the combo entirely (e.g. Shift+Tab off the button).
172
+ function onFocusout(e: FocusEvent): void {
173
+ const next: EventTarget | null = e.relatedTarget;
174
+ if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
175
+ closeList(false);
176
+ }
177
+ }
178
+
179
+ onMounted((): void => {
180
+ document.addEventListener('mousedown', onDocPointerDown);
181
+ });
182
+ onBeforeUnmount((): void => {
183
+ document.removeEventListener('mousedown', onDocPointerDown);
184
+ if (typedTimer !== null) clearTimeout(typedTimer);
185
+ });
186
+ </script>
187
+
188
+ <template>
189
+ <div
190
+ ref="rootEl"
191
+ class="fdy-combo"
192
+ :class="{ 'fdy-combo--error': isInvalid }"
193
+ :data-value="modelValue"
194
+ @keydown="onKeydown"
195
+ @focusout="onFocusout"
196
+ >
197
+ <button
198
+ :id="buttonId"
199
+ ref="buttonEl"
200
+ type="button"
201
+ class="fdy-combo__button"
202
+ :class="{ 'is-open': open }"
203
+ role="combobox"
204
+ aria-haspopup="listbox"
205
+ :aria-controls="listboxId"
206
+ :aria-expanded="open"
207
+ :aria-activedescendant="activeDescendant"
208
+ :aria-labelledby="ariaLabelledby"
209
+ :aria-invalid="isInvalid ? 'true' : undefined"
210
+ :aria-describedby="describedby"
211
+ :disabled="isDisabled"
212
+ @click="toggle"
213
+ >
214
+ <span class="fdy-combo__value" :class="{ 'fdy-combo__value--placeholder': isPlaceholder }">{{ selectedLabel }}</span>
215
+ </button>
216
+ <ul :id="listboxId" ref="listboxEl" class="fdy-combo__listbox" role="listbox" popover="manual" :hidden="!open">
217
+ <li
218
+ v-for="(opt, i) in options"
219
+ :id="optionId(i)"
220
+ :key="opt.value"
221
+ class="fdy-combo__option"
222
+ :class="{ 'is-highlighted': i === highlighted }"
223
+ role="option"
224
+ :aria-selected="opt.value === modelValue"
225
+ @click="choose(i)"
226
+ @mousemove="setHighlight(i)"
227
+ >
228
+ <span class="fdy-combo__check">{{ opt.value === modelValue ? '✓' : '' }}</span>{{ opt.label }}
229
+ </li>
230
+ </ul>
231
+ </div>
232
+ </template>