@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
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Freeday
2
+
3
+ > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
4
+
5
+ [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
6
+ [![Release](https://img.shields.io/badge/release-v1.7.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.7.0)
7
+
8
+ Token-driven, framework-agnostic UI KIT — satu sumber kebenaran untuk warna, tipografi,
9
+ spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
10
+ **Referensi hidup:** **[cahyo-dimas.github.io/freeday-ui-kit](https://cahyo-dimas.github.io/freeday-ui-kit/)** — atau buka `docs/index.html` langsung di browser.
11
+
12
+ > 🚀 **Baru mau pakai di project?** Langkah demi langkah per stack (HTML · Vue · React · Blazor): **[`docs/getting-started.md`](docs/getting-started.md)**.
13
+
14
+ ## Build
15
+ ```bash
16
+ node tokens/build.mjs # tokens.json -> dist/freeday.tokens.css; bundel dist/freeday.css;
17
+ # dist/freeday.bundle.css (token+komponen); salinan dist/*.js + dist/freeday.js
18
+ npm test # test transformasi build + kontras WCAG (node:test)
19
+ ```
20
+ `dist/` di-commit — konsumen tak wajib build sendiri.
21
+
22
+ ## Pakai di project
23
+
24
+ **Sebagai paket (project dengan bundler — Vue/React/Blazor/Vite):**
25
+ ```bash
26
+ npm i @cahyo-dimas/freeday
27
+ ```
28
+ ```js
29
+ import '@cahyo-dimas/freeday/css'; // token + komponen (satu file)
30
+ import '@cahyo-dimas/freeday'; // semua enhancer JS (auto-init [data-fdy-*])
31
+ // granular bila perlu: '@cahyo-dimas/freeday/tokens' · '@cahyo-dimas/freeday/css/components' · '@cahyo-dimas/freeday/enhancers/<nama>'
32
+ ```
33
+ Set tema di root app: `<html data-theme="light" data-density="comfortable">`. `dist/` di-commit &
34
+ ter-publish → install jalan **tanpa build step**; minify diserahkan ke bundler konsumen. Karena
35
+ terbit di **npm publik**, `npm ci` di CI jalan tanpa auth/SSH key.
36
+
37
+ > **Dari source (tanpa registry):** `npm i github:cahyo-dimas/freeday-ui-kit`.
38
+
39
+ **Atau link file langsung (tanpa build):**
40
+
41
+ ### 1. Sertakan CSS (wajib)
42
+ ```html
43
+ <html lang="id" data-theme="light" data-density="comfortable">
44
+ <link rel="stylesheet" href="dist/freeday.tokens.css"> <!-- token: warna, spasi, dst -->
45
+ <link rel="stylesheet" href="dist/freeday.css"> <!-- komponen fdy-* -->
46
+ ```
47
+ Kelas komponen berprefix `fdy-` (mis. `fdy-btn`, `fdy-card`, `fdy-badge`). Pakai langsung di
48
+ markup framework apa pun — Vue, React, Blazor, HTML polos.
49
+
50
+ > `.fdy-btn` **sudah** tombol primary — tidak ada modifier `.fdy-btn--primary` terpisah.
51
+ > Modifier yang tersedia untuk varian lain: `--ghost`, `--danger`, `--text`, `--sm`, `--lg`,
52
+ > `--icon` (lihat `docs/index.html`).
53
+
54
+ ### 2. Sertakan JS enhancer (opsional, 0 dependency)
55
+ Komponen interaktif (dropdown, tabs, tabel, choose-from-list, datepicker, upload, toast)
56
+ butuh JS. Dua cara:
57
+ ```html
58
+ <!-- a) satu bundel semua enhancer -->
59
+ <script src="dist/freeday.js" defer></script>
60
+
61
+ <!-- b) atau pilih per-file yang dipakai saja -->
62
+ <script src="dist/freeday-select.js" defer></script> <!-- [data-fdy-combo] -->
63
+ <script src="dist/freeday-tabs.js" defer></script> <!-- [data-fdy-tabs] -->
64
+ <script src="dist/freeday-table.js" defer></script> <!-- [data-fdy-table] -->
65
+ <script src="dist/freeday-cfl.js" defer></script> <!-- [data-fdy-cfl] + <dialog> -->
66
+ <script src="dist/freeday-datepicker.js" defer></script> <!-- [data-fdy-datepicker] -->
67
+ <script src="dist/freeday-upload.js" defer></script> <!-- [data-fdy-dropzone] -->
68
+ <script src="dist/freeday-toast.js" defer></script> <!-- Freeday.toast({...}) -->
69
+ ```
70
+ Semua auto-init `[data-fdy-*]` saat `DOMContentLoaded`, idempotent, dan progressive-enhancement.
71
+
72
+ | Enhancer | Hook markup | Event / API |
73
+ |---|---|---|
74
+ | `freeday-select` | `[data-fdy-combo]` | `fdy-change` `{value}` · `window.FreedayCombo` |
75
+ | `freeday-tabs` | `[data-fdy-tabs]` | `window.FreedayTabs` |
76
+ | `freeday-table` | `[data-fdy-table]` (+ `[data-fdy-filter]`, `[data-fdy-table-bulk]`) | `fdy-table-change` · `window.FreedayTable` |
77
+ | `freeday-cfl` | `[data-fdy-cfl]` → `<dialog>` | `fdy-cfl-select` `{row}`/`{rows}` · `window.FreedayCfl` |
78
+ | `freeday-datepicker` | `[data-fdy-datepicker]`, `[data-fdy-daterange]` | `fdy-datepicker-change` `{value,date}` · `window.FreedayDatepicker` |
79
+ | `freeday-timepicker` | `[data-fdy-timepicker]` | `fdy-time-select` `{value}` · `window.FreedayTimepicker` |
80
+ | `freeday-datetime` | `[data-fdy-datetimepicker]` | `fdy-datetime-change` `{date,time,value}` · `window.FreedayDatetime` |
81
+ | `freeday-cascade` | `[data-fdy-cascade]` (nested `<ul>` model) | `fdy-cascade-change` `{value,path,labels}` · `window.FreedayCascade` |
82
+ | `freeday-mask` | `[data-fdy-mask]`, `[data-fdy-password]` | `fdy-mask` `{value,raw}` · `window.FreedayMask` |
83
+ | `freeday-form` | `[data-fdy-validate]` (form) | `fdy-form-invalid`/`-valid` · `window.FreedayForm` |
84
+ | `freeday-chip` | `[data-fdy-chips]`, `.fdy-chip__remove` | `fdy-chip-change`/`fdy-chip-remove` · `window.FreedayChip` |
85
+ | `freeday-upload` | `[data-fdy-dropzone]` | `fdy-upload-add`/`-remove` · `window.FreedayUpload` |
86
+ | `freeday-toast` | — | `Freeday.toast({variant,title,message,timeout})` |
87
+
88
+ Tabel lebar (banyak kolom) butuh wrapper untuk scroll horizontal: bungkus `.fdy-table` dengan
89
+ `.fdy-table-wrap` (tabel biasa, sudah termasuk border+shadow shell) atau `.fdy-table-scroll`
90
+ (scroll polos tanpa shell — jalan standalone maupun di dalam `.fdy-datatable` yang shell
91
+ border/shadow-nya sudah ada sendiri). Tanpa salah satu
92
+ wrapper ini tabel lebar akan overflow container-nya, bukan scroll sendiri.
93
+
94
+ ### 3. Theming — 3 sumbu lewat `data-*` di root
95
+ - `data-theme="light|dark"` — redefinisi token semantic (bind ke state tema app-mu).
96
+ - `data-density="comfortable|compact"` — tinggi kontrol (`--control-h`) untuk layar data-dense.
97
+ Ini auto-apply hanya ke kontrol bawaan Freeday (button, input, combo, dst); komponen
98
+ custom/hand-built harus baca `--control-h` sendiri (mis. `height:var(--control-h)`) supaya
99
+ ikut menyusut/melebar saat `data-density` berubah.
100
+ - (roadmap) `data-style` — varian visual lain.
101
+ - Breakpoint scale (`sm`/`md`/`lg`/`xl` = 600/960/1280/1920px, sama dengan utilitas
102
+ `src/components/breakpoints.css`) juga tersedia di JS: `import { breakpoints } from
103
+ '@cahyo-dimas/freeday/breakpoints'` — dipakai untuk menyamakan `matchMedia`/`@media` app-mu ke skala Freeday.
104
+
105
+ ## Integrasi framework (SPA)
106
+ > **Peta library lengkap:** [`docs/integrations.md`](docs/integrations.md) — tiap area
107
+ > (form/validasi, chart, tabel, tanggal, overlay, dst) dipetakan ke library ekosistem yang
108
+ > biasa dipasang (Zod/Yup, Chart.js, TanStack Table, date-fns, Floating UI, …) + cara
109
+ > menjembataninya + binding Vue/React/Blazor. Buka itu saat mulai project baru.
110
+
111
+ **Adapter siap pakai — Vue · React · Blazor.** Semua tipis: enhancer tetap sumber kebenaran,
112
+ adapter hanya hydrate + jembatani event. Tiap punya contoh layar **faktur** yang jalan:
113
+
114
+ ```ts
115
+ // Vue 3 — @cahyo-dimas/freeday/vue
116
+ import { useFreeday } from '@cahyo-dimas/freeday/vue';
117
+ const root = ref<HTMLElement | null>(null);
118
+ useFreeday(root); // @fdy-cascade-change="…" (detail bertipe)
119
+ ```
120
+ ```tsx
121
+ // React — @cahyo-dimas/freeday/react
122
+ import { useFreeday } from '@cahyo-dimas/freeday/react';
123
+ const root = useRef<HTMLDivElement>(null);
124
+ useFreeday(root); // event fdy-* bubbling → listen di root
125
+ ```
126
+ ```csharp
127
+ // Blazor — @cahyo-dimas/freeday/blazor (window.FreedayBlazor via JS interop)
128
+ await JS.InvokeVoidAsync("FreedayBlazor.initAll", _root);
129
+ await JS.InvokeAsync<int>("FreedayBlazor.on", _root, "fdy-cascade-change", _self, nameof(OnCascade));
130
+ ```
131
+
132
+ | Framework | Adapter | Contoh jalan |
133
+ |---|---|---|
134
+ | Vue 3 | `@cahyo-dimas/freeday/vue` | [`examples/vue-faktur/`](examples/vue-faktur/) (`npm install && npm run dev`) |
135
+ | React 19 | `@cahyo-dimas/freeday/react` | [`examples/react-faktur/`](examples/react-faktur/) (`npm install && npm run dev`) |
136
+ | Blazor WASM (.NET 10) | `@cahyo-dimas/freeday/blazor` | [`examples/blazor-faktur/`](examples/blazor-faktur/) (`dotnet run`) |
137
+
138
+ Peta library & pola lengkap: [`docs/integrations.md`](docs/integrations.md).
139
+
140
+ Secara umum, enhancer meng-auto-init sekali saat load. Untuk DOM yang dirender dinamis (Vue/React/Blazor):
141
+ - **Reuse enhancer:** setelah mount/route change, panggil `window.FreedayTable.initAll(el)`
142
+ (atau `initAll()` global). Aman diulang — tiap init dijaga flag idempotent. Jembatani ke
143
+ state framework lewat event yang dipancarkan (mis. dengarkan `fdy-cfl-select`, `fdy-datepicker-change`).
144
+ - **Atau re-implement:** tulis komponen framework sendiri, pertahankan **markup + kontrak ARIA
145
+ + kelas `fdy-*`** yang sama (lihat `docs/index.html`). Enhancer adalah implementasi rujukan,
146
+ bukan keharusan. Untuk choose-from-list, jadikan komponen terkontrol (`fetchPage` callback +
147
+ server-cache), jangan mirror ke store global.
148
+
149
+ ## Aturan token — 3 lapis (jangan dilanggar)
150
+ ```
151
+ Tier 1 PRIMITIVE ramp mentah (--azure-600…) — TAK PERNAH dipakai di komponen
152
+ Tier 2 SEMANTIC peran (--color-primary, --color-surface…) — berubah saat theme & re-brand
153
+ Tier 3 COMPONENT --fdy-<komp>-<prop> — opsional, override lokal
154
+ ```
155
+ Komponen hanya menyentuh Tier 2/3. Butuh nilai baru → compose → extend modifier → baru create.
156
+
157
+ ## Struktur repo
158
+ ```
159
+ tokens/tokens.json sumber sejati (edit di sini)
160
+ tokens/build.mjs generator (Node murni, 0 dependency)
161
+ src/base.css reset + utilitas
162
+ src/components/*.css satu file per komponen (fdy-*)
163
+ src/*.js enhancer JS opsional (rujukan, vanilla)
164
+ dist/ hasil build (DI-COMMIT):
165
+ freeday.tokens.css token semantic (light/dark/compact)
166
+ freeday.css bundel semua komponen
167
+ freeday.js bundel semua enhancer (satu <script>)
168
+ freeday-*.js enhancer per-file
169
+ docs/index.html referensi hidup / demo-site
170
+ ```
171
+
172
+ ## Inventaris komponen
173
+ - **Fondasi:** warna semantic, tipografi (Sora/IBM Plex Sans/JetBrains Mono), skala spasi 4px,
174
+ radius & elevasi, motion, checklist aksesibilitas.
175
+ - **Aksi & form:** button, input (+error), input-group (Rp/%/ikon), checkbox/radio/switch,
176
+ **select `fdy-combo`** (APG), **autocomplete**, **cascade select** (hierarki drill-down),
177
+ **choose-from-list** (field + dialog single/multi), **date / time / datetime picker**,
178
+ **file upload** (dropzone + state per-berkas), **password reveal + input mask**,
179
+ **form validation** (Constraint Validation API → error aksesibel).
180
+ - **Data:** table, **data table** (cari · sort · **filter per-kolom** teks/enum/angka · **bulk
181
+ actions** · paginasi · seleksi), states (empty/loading/error).
182
+ - **Feedback:** alert, toast, tooltip. **Navigasi:** tabs, breadcrumb, pagination.
183
+ - **Tampilan:** card, badge, avatar, chip (default/hapus · choice · filter), description-list,
184
+ progress, spinner, skeleton.
185
+ - **Layout:** app shell, accordion (native `<details>`), modal (native `<dialog>`), divider, kbd.
186
+
187
+ ## Aksesibilitas
188
+ Kontras WCAG AA (terang & gelap) — diaudit otomatis oleh `test/contrast.test.mjs` yang
189
+ menyelesaikan graf token, meng-*composite* fill `-soft` semi-transparan di atas surface-nya,
190
+ dan menegakkan tiap pasangan (teks 4.5:1; batas kontrol / ikon 3:1, WCAG 1.4.11). Border kontrol
191
+ form pakai `--color-control-border` (≥3:1); border dekoratif sengaja tetap terang.
192
+ `:focus-visible` selalu terlihat, HTML native dulu sebelum ARIA, komponen interaktif ikut pola
193
+ WAI-ARIA APG, hormati `prefers-reduced-motion`. Status tak hanya lewat warna. Lihat section
194
+ "Aksesibilitas" di `docs/index.html`.
195
+
196
+ ## Dukungan browser
197
+ Butuh browser evergreen ~2023+: **Chrome 111 · Safari 16.4 · Firefox 113** (floor ditentukan
198
+ oleh `color-mix()`). Fitur lain: native `<dialog>`/`::backdrop`, `accent-color`, `100dvh`,
199
+ `conic-gradient`, `scroll-snap` — semua ≥ Safari 15.4. Blur backdrop pakai
200
+ `-webkit-backdrop-filter` + `backdrop-filter` (Safari lama tetap jalan, hanya tanpa blur).
201
+ Belum ada CSS build/autoprefixer — dukungan browser lama = tanggung jawab konsumen.
202
+
203
+ ## Lisensi
204
+ [MIT](LICENSE) © 2026 Cahyo D. Kurnianto — bebas dipakai, diubah, dan didistribusikan asal
205
+ menyertakan baris copyright + teks lisensi.
@@ -0,0 +1,79 @@
1
+ /* Freeday — Blazor JS interop (classic IIFE; registers window.FreedayBlazor).
2
+ * Consistent with the other Freeday enhancers (plain <script>, no ES module),
3
+ * so it serves from any static host without module-MIME strictness.
4
+ *
5
+ * Load it after dist/freeday.js in wwwroot, then call from a component:
6
+ * await JS.InvokeVoidAsync("FreedayBlazor.initAll", elementRef); // after render
7
+ * var token = await JS.InvokeAsync<int>("FreedayBlazor.on",
8
+ * elementRef, "fdy-cascade-change", dotNetRef, "OnCascade"); // [JSInvokable]
9
+ * await JS.InvokeVoidAsync("FreedayBlazor.off", token); // on dispose
10
+ *
11
+ * Requires dist/freeday.js loaded first (registers window.Freeday*). The
12
+ * enhancers stay the source of truth; this only bridges hydrate + events to .NET.
13
+ */
14
+ (function () {
15
+ 'use strict';
16
+
17
+ // Re-run every registered Freeday enhancer over an element (or the whole document).
18
+ function initAll(element) {
19
+ var names = Object.getOwnPropertyNames(window);
20
+ for (var i = 0; i < names.length; i++) {
21
+ var key = names[i];
22
+ if (key.slice(0, 7) === 'Freeday' && key !== 'FreedayBlazor') {
23
+ var api = window[key];
24
+ if (api && typeof api.initAll === 'function') api.initAll(element || undefined);
25
+ }
26
+ }
27
+ }
28
+
29
+ // JSON-safe copy of an event detail so it can cross to .NET (drop DOM nodes /
30
+ // functions; a Date serialises to an ISO string, which .NET parses fine).
31
+ function safeDetail(detail) {
32
+ if (detail == null) return null;
33
+ try {
34
+ return JSON.parse(JSON.stringify(detail, function (k, v) {
35
+ if (v instanceof Node) return undefined;
36
+ if (typeof v === 'function') return undefined;
37
+ return v;
38
+ }));
39
+ } catch (e) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ var subs = {};
45
+ var seq = 0;
46
+
47
+ // Subscribe to a bubbling fdy-* event on `element` (or document), forwarding the
48
+ // event detail to a .NET [JSInvokable] method. Returns a token for off().
49
+ function on(element, eventName, dotNetRef, methodName) {
50
+ var el = element || document;
51
+ var handler = function (e) { dotNetRef.invokeMethodAsync(methodName, safeDetail(e.detail)); };
52
+ el.addEventListener(eventName, handler);
53
+ var token = ++seq;
54
+ subs[token] = { el: el, type: eventName, fn: handler };
55
+ return token;
56
+ }
57
+
58
+ // Remove a subscription created by on().
59
+ function off(token) {
60
+ var s = subs[token];
61
+ if (s) {
62
+ s.el.removeEventListener(s.type, s.fn);
63
+ delete subs[token];
64
+ }
65
+ }
66
+
67
+ // Passthrough to the Freeday toast API.
68
+ function toast(options) {
69
+ if (window.Freeday && typeof window.Freeday.toast === 'function') window.Freeday.toast(options);
70
+ }
71
+
72
+ // Flip the document theme (data-theme on <html>), for the demo toggle.
73
+ function toggleTheme() {
74
+ var e = document.documentElement;
75
+ e.setAttribute('data-theme', e.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
76
+ }
77
+
78
+ window.FreedayBlazor = { initAll: initAll, on: on, off: off, toast: toast, toggleTheme: toggleTheme };
79
+ })();
@@ -0,0 +1,159 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A controlled React port of freeday's autocomplete (src/freeday-autocomplete.js +
6
+ // autocomplete.css): a WAI-ARIA APG *editable* combobox — a text input filters a listbox of
7
+ // options as you type; picking one fills the input. `value` + `onChange` in place of the
8
+ // enhancer's DOM mutation, so React owns the input. Filtering mirrors the enhancer exactly
9
+ // (case-insensitive substring on the trimmed query); pass server-filtered `options` and it
10
+ // stays a no-op re-filter. `onSelect` fires only on commit (the enhancer's select event).
11
+
12
+ export interface FdyAutocompleteProps {
13
+ value: string;
14
+ onChange: (value: string) => void;
15
+ options: ReadonlyArray<string>;
16
+ /** Fired only when an option is committed (click / Enter), not on every keystroke. */
17
+ onSelect?: (value: string) => void;
18
+ emptyText?: string;
19
+ placeholder?: string;
20
+ id?: string;
21
+ ariaLabel?: string;
22
+ ariaLabelledby?: string;
23
+ disabled?: boolean;
24
+ invalid?: boolean;
25
+ describedby?: string;
26
+ }
27
+
28
+ export function FdyAutocomplete(props: FdyAutocompleteProps): JSX.Element {
29
+ const baseId: string = useId();
30
+ const inputId: string = props.id ?? `${baseId}-input`;
31
+ const listboxId: string = `${baseId}-listbox`;
32
+ const optionId = (index: number): string => `${baseId}-opt-${index}`;
33
+
34
+ const rootRef = useRef<HTMLDivElement>(null);
35
+ const inputRef = useRef<HTMLInputElement>(null);
36
+ const listboxRef = useRef<HTMLUListElement>(null);
37
+ const [open, setOpen] = useState<boolean>(false);
38
+ const [active, setActive] = useState<number>(-1);
39
+
40
+ usePopover(listboxRef, inputRef, open);
41
+ // Popover attr for React 18/19 JSX typing: set once on mount (same as FdyCombo).
42
+ useEffect((): void => {
43
+ listboxRef.current?.setAttribute('popover', 'manual');
44
+ }, []);
45
+
46
+ const isDisabled: boolean = props.disabled === true;
47
+ const isInvalid: boolean = props.invalid === true;
48
+
49
+ const filtered: string[] = useMemo((): string[] => {
50
+ const q: string = props.value.trim().toLowerCase();
51
+ return q === '' ? props.options.slice() : props.options.filter((o: string): boolean => o.toLowerCase().includes(q));
52
+ }, [props.options, props.value]);
53
+
54
+ const activeDescendant: string | undefined =
55
+ open && active >= 0 && active < filtered.length ? optionId(active) : undefined;
56
+
57
+ const openList = (): void => { if (!isDisabled) setOpen(true); };
58
+ const closeList = (): void => { setOpen(false); setActive(-1); };
59
+
60
+ const choose = (label: string): void => {
61
+ props.onChange(label);
62
+ if (props.onSelect !== undefined) props.onSelect(label);
63
+ closeList();
64
+ inputRef.current?.focus();
65
+ };
66
+
67
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
68
+ if (isDisabled) return;
69
+ switch (e.key) {
70
+ case 'ArrowDown':
71
+ e.preventDefault();
72
+ if (!open) { openList(); setActive(filtered.length > 0 ? 0 : -1); }
73
+ else setActive(active + 1 < filtered.length ? active + 1 : 0);
74
+ break;
75
+ case 'ArrowUp':
76
+ e.preventDefault();
77
+ if (!open) { openList(); setActive(filtered.length - 1); }
78
+ else setActive(active - 1 >= 0 ? active - 1 : filtered.length - 1);
79
+ break;
80
+ case 'Enter':
81
+ if (open && active >= 0 && active < filtered.length) { e.preventDefault(); choose(filtered[active]); }
82
+ break;
83
+ case 'Escape':
84
+ if (open) { e.preventDefault(); closeList(); }
85
+ break;
86
+ case 'Tab':
87
+ if (open) closeList();
88
+ break;
89
+ default: break;
90
+ }
91
+ };
92
+
93
+ // Close when a pointer lands outside the whole component.
94
+ useEffect((): void | (() => void) => {
95
+ if (!open) return;
96
+ const onDocPointerDown = (e: MouseEvent): void => {
97
+ const t: EventTarget | null = e.target;
98
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closeList();
99
+ };
100
+ document.addEventListener('mousedown', onDocPointerDown);
101
+ return (): void => document.removeEventListener('mousedown', onDocPointerDown);
102
+ }, [open]);
103
+
104
+ // Keep the highlighted option in view (indexed, so React's useId colons never hit a selector).
105
+ useEffect((): void => {
106
+ if (!open || active < 0) return;
107
+ const items: NodeListOf<Element> | undefined = listboxRef.current?.querySelectorAll('[role="option"]');
108
+ const el: Element | undefined = items?.[active];
109
+ if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' });
110
+ }, [active, open]);
111
+
112
+ return (
113
+ <div ref={rootRef} className="fdy-autocomplete" onKeyDown={onKeyDown}>
114
+ <input
115
+ ref={inputRef}
116
+ id={inputId}
117
+ className={isInvalid ? 'fdy-input fdy-input--error' : 'fdy-input'}
118
+ type="text"
119
+ role="combobox"
120
+ aria-expanded={open}
121
+ aria-autocomplete="list"
122
+ aria-controls={listboxId}
123
+ aria-activedescendant={activeDescendant}
124
+ aria-label={props.ariaLabel}
125
+ aria-labelledby={props.ariaLabelledby}
126
+ aria-invalid={isInvalid ? 'true' : undefined}
127
+ aria-describedby={props.describedby}
128
+ autoComplete="off"
129
+ placeholder={props.placeholder}
130
+ disabled={isDisabled}
131
+ value={props.value}
132
+ onChange={(e: React.ChangeEvent<HTMLInputElement>): void => { props.onChange(e.target.value); setActive(-1); openList(); }}
133
+ onFocus={openList}
134
+ />
135
+ <ul
136
+ ref={listboxRef}
137
+ id={listboxId}
138
+ className="fdy-autocomplete__listbox"
139
+ role="listbox"
140
+ aria-label={props.ariaLabel}
141
+ hidden={!open}
142
+ >
143
+ {filtered.map((opt: string, i: number): JSX.Element => (
144
+ <li
145
+ key={opt}
146
+ id={optionId(i)}
147
+ className={i === active ? 'fdy-autocomplete__option is-highlighted' : 'fdy-autocomplete__option'}
148
+ role="option"
149
+ aria-selected={opt === props.value}
150
+ onMouseDown={(e: React.MouseEvent<HTMLLIElement>): void => e.preventDefault()}
151
+ onMouseMove={(): void => setActive(i)}
152
+ onClick={(): void => choose(opt)}
153
+ >{opt}</li>
154
+ ))}
155
+ {filtered.length === 0 ? <li className="fdy-autocomplete__empty">{props.emptyText ?? 'Tak ada hasil.'}</li> : null}
156
+ </ul>
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,230 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A controlled React port of freeday's cascade select (src/freeday-cascade.js + cascade.css):
6
+ // a hierarchical drill-down picker showing one level at a time — branches drill in, a back
7
+ // control ascends, a leaf selects and the value is the leaf's value (the display is the full
8
+ // path). The enhancer's data model is a hidden nested <ul>; here it is a typed tree, which is
9
+ // what a framework app actually has. `value` + `onChange` in place of the DOM mutation.
10
+
11
+ export interface CascadeNode {
12
+ label: string;
13
+ value: string;
14
+ /** Present = branch (drills in); absent = leaf (selectable). */
15
+ children?: ReadonlyArray<CascadeNode>;
16
+ }
17
+
18
+ export interface FdyCascadeProps {
19
+ /** The selected LEAF value ('' = nothing selected). */
20
+ value: string;
21
+ /** Fired when a leaf is picked; `labels` is the full path (root → leaf). */
22
+ onChange: (value: string, labels: string[]) => void;
23
+ options: ReadonlyArray<CascadeNode>;
24
+ /** Path separator in the display, default " / " (matches the enhancer). */
25
+ separator?: string;
26
+ placeholder?: string;
27
+ /** Accessible name for the trigger + listbox (the enhancer's data-label). */
28
+ label?: string;
29
+ id?: string;
30
+ ariaLabelledby?: string;
31
+ disabled?: boolean;
32
+ invalid?: boolean;
33
+ describedby?: string;
34
+ }
35
+
36
+ // Depth-first search for the stack of nodes leading to a leaf value.
37
+ function pathTo(nodes: ReadonlyArray<CascadeNode>, value: string, trail: CascadeNode[]): CascadeNode[] | null {
38
+ for (const node of nodes) {
39
+ const here: CascadeNode[] = trail.concat([node]);
40
+ if (node.children === undefined && node.value === value) return here;
41
+ if (node.children !== undefined) {
42
+ const found: CascadeNode[] | null = pathTo(node.children, value, here);
43
+ if (found !== null) return found;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
50
+ const baseId: string = useId();
51
+ const triggerId: string = props.id ?? `${baseId}-trigger`;
52
+ const listId: string = `${baseId}-list`;
53
+ const optionId = (index: number): string => `${baseId}-opt-${index}`;
54
+ const sep: string = props.separator ?? ' / ';
55
+ const name: string = props.label ?? 'Pilih';
56
+
57
+ const rootRef = useRef<HTMLDivElement>(null);
58
+ const triggerRef = useRef<HTMLButtonElement>(null);
59
+ const panelRef = useRef<HTMLDivElement>(null);
60
+ const listRef = useRef<HTMLUListElement>(null);
61
+
62
+ const [open, setOpen] = useState<boolean>(false);
63
+ const [stack, setStack] = useState<CascadeNode[]>([]);
64
+ const [active, setActive] = useState<number>(-1);
65
+
66
+ usePopover(panelRef, triggerRef, open);
67
+ // Popover attr for React 18/19 JSX typing: set once on mount (same as FdyCombo).
68
+ useEffect((): void => {
69
+ panelRef.current?.setAttribute('popover', 'manual');
70
+ }, []);
71
+
72
+ const isDisabled: boolean = props.disabled === true;
73
+ const isInvalid: boolean = props.invalid === true;
74
+
75
+ const current: ReadonlyArray<CascadeNode> =
76
+ stack.length === 0 ? props.options : (stack[stack.length - 1].children ?? []);
77
+
78
+ const selectedTrail: CascadeNode[] | null = useMemo(
79
+ (): CascadeNode[] | null => (props.value === '' ? null : pathTo(props.options, props.value, [])),
80
+ [props.options, props.value],
81
+ );
82
+ const isPlaceholder: boolean = selectedTrail === null;
83
+ const displayValue: string = selectedTrail !== null
84
+ ? selectedTrail.map((n: CascadeNode): string => n.label).join(sep)
85
+ : (props.placeholder ?? 'Pilih…');
86
+ const crumb: string = stack.length > 0 ? stack.map((n: CascadeNode): string => n.label).join(sep) : name;
87
+
88
+ const openPanel = (): void => {
89
+ if (isDisabled) return;
90
+ // Re-open at the selected leaf's level for quick re-selection (matches the enhancer).
91
+ setStack(selectedTrail !== null && selectedTrail.length > 1 ? selectedTrail.slice(0, -1) : []);
92
+ setActive(0);
93
+ setOpen(true);
94
+ };
95
+ const closePanel = (returnFocus: boolean): void => {
96
+ setOpen(false);
97
+ setActive(-1);
98
+ if (returnFocus) triggerRef.current?.focus();
99
+ };
100
+ const drill = (index: number): void => {
101
+ const node: CascadeNode | undefined = current[index];
102
+ if (node === undefined || node.children === undefined) return;
103
+ setStack(stack.concat([node]));
104
+ setActive(0);
105
+ };
106
+ const ascend = (): void => {
107
+ if (stack.length === 0) return;
108
+ setStack(stack.slice(0, -1));
109
+ setActive(0);
110
+ };
111
+ const activate = (index: number): void => {
112
+ const node: CascadeNode | undefined = current[index];
113
+ if (node === undefined) return;
114
+ if (node.children !== undefined) { drill(index); return; }
115
+ const labels: string[] = stack.map((n: CascadeNode): string => n.label).concat([node.label]);
116
+ props.onChange(node.value, labels);
117
+ closePanel(true);
118
+ };
119
+
120
+ const onListKeyDown = (e: React.KeyboardEvent<HTMLUListElement>): void => {
121
+ switch (e.key) {
122
+ case 'ArrowDown': e.preventDefault(); setActive(Math.min(current.length - 1, active + 1)); break;
123
+ case 'ArrowUp': e.preventDefault(); setActive(Math.max(0, active - 1)); break;
124
+ case 'Home': e.preventDefault(); setActive(0); break;
125
+ case 'End': e.preventDefault(); setActive(current.length - 1); break;
126
+ case 'ArrowRight':
127
+ case 'Enter':
128
+ case ' ': e.preventDefault(); if (active >= 0) activate(active); break;
129
+ case 'ArrowLeft':
130
+ case 'Backspace': e.preventDefault(); ascend(); break;
131
+ case 'Escape': e.preventDefault(); closePanel(true); break;
132
+ case 'Tab': closePanel(false); break;
133
+ default: break;
134
+ }
135
+ };
136
+
137
+ const onTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>): void => {
138
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); }
139
+ };
140
+
141
+ // Move focus into the list when the panel opens (the enhancer does list.focus()).
142
+ useEffect((): void => {
143
+ if (open) listRef.current?.focus();
144
+ }, [open]);
145
+
146
+ // Close when a pointer lands outside the whole component.
147
+ useEffect((): void | (() => void) => {
148
+ if (!open) return;
149
+ const onDocPointerDown = (e: MouseEvent): void => {
150
+ const t: EventTarget | null = e.target;
151
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closePanel(false);
152
+ };
153
+ document.addEventListener('mousedown', onDocPointerDown);
154
+ return (): void => document.removeEventListener('mousedown', onDocPointerDown);
155
+ }, [open]);
156
+
157
+ // Keep the active option in view (indexed, so React's useId colons never hit a selector).
158
+ useEffect((): void => {
159
+ if (!open || active < 0) return;
160
+ const items: NodeListOf<Element> | undefined = listRef.current?.querySelectorAll('[role="option"]');
161
+ const el: Element | undefined = items?.[active];
162
+ if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' });
163
+ }, [active, open, stack]);
164
+
165
+ return (
166
+ <div ref={rootRef} className={isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'}>
167
+ <button
168
+ ref={triggerRef}
169
+ id={triggerId}
170
+ type="button"
171
+ className={open ? 'fdy-cascade__trigger is-open' : 'fdy-cascade__trigger'}
172
+ aria-haspopup="true"
173
+ aria-expanded={open}
174
+ aria-label={props.ariaLabelledby === undefined ? name : undefined}
175
+ aria-labelledby={props.ariaLabelledby}
176
+ aria-invalid={isInvalid ? 'true' : undefined}
177
+ aria-describedby={props.describedby}
178
+ disabled={isDisabled}
179
+ onClick={(): void => { if (open) closePanel(true); else openPanel(); }}
180
+ onKeyDown={onTriggerKeyDown}
181
+ >
182
+ <span className={isPlaceholder ? 'fdy-cascade__value fdy-cascade__value--placeholder' : 'fdy-cascade__value'}>{displayValue}</span>
183
+ </button>
184
+
185
+ <div ref={panelRef} className="fdy-cascade__panel" hidden={!open}>
186
+ <div className="fdy-cascade__head">
187
+ <button
188
+ type="button"
189
+ className="fdy-cascade__back"
190
+ aria-label="Kembali satu tingkat"
191
+ hidden={stack.length === 0}
192
+ onClick={(): void => { ascend(); listRef.current?.focus(); }}
193
+ >
194
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
195
+ </button>
196
+ <span className="fdy-cascade__crumb" aria-live="polite">{crumb}</span>
197
+ </div>
198
+ <ul
199
+ ref={listRef}
200
+ id={listId}
201
+ className="fdy-cascade__list"
202
+ role="listbox"
203
+ aria-label={name}
204
+ tabIndex={-1}
205
+ aria-activedescendant={active >= 0 ? optionId(active) : undefined}
206
+ onKeyDown={onListKeyDown}
207
+ >
208
+ {current.map((node: CascadeNode, i: number): JSX.Element => {
209
+ const isBranch: boolean = node.children !== undefined;
210
+ return (
211
+ <li
212
+ key={node.value}
213
+ id={optionId(i)}
214
+ role="option"
215
+ className={i === active ? 'fdy-cascade__opt is-active' : 'fdy-cascade__opt'}
216
+ aria-selected={!isBranch && node.value === props.value}
217
+ aria-label={isBranch ? `${node.label}, submenu` : undefined}
218
+ onMouseMove={(): void => setActive(i)}
219
+ onClick={(): void => activate(i)}
220
+ >
221
+ <span className="fdy-cascade__opt-label">{node.label}</span>
222
+ {isBranch ? <span className="fdy-cascade__opt-arrow" aria-hidden="true" /> : null}
223
+ </li>
224
+ );
225
+ })}
226
+ </ul>
227
+ </div>
228
+ </div>
229
+ );
230
+ }