@cahyo-dimas/freeday 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +447 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/adapters/blazor/freeday-blazor.js +79 -0
- package/adapters/react/components/FdyAutocomplete.tsx +159 -0
- package/adapters/react/components/FdyCascade.tsx +230 -0
- package/adapters/react/components/FdyCfl.tsx +362 -0
- package/adapters/react/components/FdyChart.tsx +69 -0
- package/adapters/react/components/FdyCombo.tsx +166 -0
- package/adapters/react/components/FdyDateRange.tsx +66 -0
- package/adapters/react/components/FdyDatepicker.tsx +354 -0
- package/adapters/react/index.d.ts +37 -0
- package/adapters/react/index.js +9 -0
- package/adapters/react/tsconfig.json +14 -0
- package/adapters/react/useFreeday.js +44 -0
- package/adapters/react/usePopover.ts +64 -0
- package/adapters/vue/components/FdyAutocomplete.vue +174 -0
- package/adapters/vue/components/FdyCascade.vue +241 -0
- package/adapters/vue/components/FdyCfl.vue +354 -0
- package/adapters/vue/components/FdyChart.vue +89 -0
- package/adapters/vue/components/FdyCombo.vue +232 -0
- package/adapters/vue/components/FdyDateRange.vue +78 -0
- package/adapters/vue/components/FdyDatepicker.vue +333 -0
- package/adapters/vue/index.d.ts +44 -0
- package/adapters/vue/index.js +8 -0
- package/adapters/vue/useFreeday.js +44 -0
- package/adapters/vue/usePopover.ts +78 -0
- package/dist/freeday-autocomplete.js +135 -0
- package/dist/freeday-breakpoint.js +51 -0
- package/dist/freeday-carousel.js +111 -0
- package/dist/freeday-cascade.js +256 -0
- package/dist/freeday-cfl.js +213 -0
- package/dist/freeday-chart.js +403 -0
- package/dist/freeday-chip.js +83 -0
- package/dist/freeday-datepicker.js +321 -0
- package/dist/freeday-datetime.js +59 -0
- package/dist/freeday-drawer.js +43 -0
- package/dist/freeday-form.js +181 -0
- package/dist/freeday-mask.js +114 -0
- package/dist/freeday-menu.js +93 -0
- package/dist/freeday-popover.js +69 -0
- package/dist/freeday-rating.js +50 -0
- package/dist/freeday-select.js +194 -0
- package/dist/freeday-slider.js +34 -0
- package/dist/freeday-stepper.js +90 -0
- package/dist/freeday-table.js +475 -0
- package/dist/freeday-tabs.js +68 -0
- package/dist/freeday-timepicker.js +180 -0
- package/dist/freeday-toast.js +84 -0
- package/dist/freeday-tree.js +94 -0
- package/dist/freeday-upload.js +206 -0
- package/dist/freeday.bundle.css +1352 -0
- package/dist/freeday.css +1007 -0
- package/dist/freeday.js +3632 -0
- package/dist/freeday.tokens.css +343 -0
- package/package.json +72 -0
- package/src/base.css +20 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/accordion.css +13 -0
- package/src/components/alert.css +18 -0
- package/src/components/app-shell.css +88 -0
- package/src/components/appbar.css +15 -0
- package/src/components/autocomplete.css +12 -0
- package/src/components/avatar.css +8 -0
- package/src/components/badge.css +27 -0
- package/src/components/breadcrumb.css +9 -0
- package/src/components/breakpoints.css +15 -0
- package/src/components/button.css +58 -0
- package/src/components/card.css +9 -0
- package/src/components/carousel.css +17 -0
- package/src/components/cascade.css +36 -0
- package/src/components/cfl.css +28 -0
- package/src/components/chart.css +55 -0
- package/src/components/chip.css +18 -0
- package/src/components/combo.css +27 -0
- package/src/components/datepicker.css +44 -0
- package/src/components/datetimepicker.css +8 -0
- package/src/components/description-list.css +6 -0
- package/src/components/divider.css +3 -0
- package/src/components/drawer.css +25 -0
- package/src/components/file-upload.css +34 -0
- package/src/components/filterbar.css +25 -0
- package/src/components/form-grid.css +8 -0
- package/src/components/input-group.css +22 -0
- package/src/components/input.css +13 -0
- package/src/components/kbd.css +2 -0
- package/src/components/menu.css +28 -0
- package/src/components/modal.css +22 -0
- package/src/components/pagination.css +8 -0
- package/src/components/progress.css +7 -0
- package/src/components/rating.css +10 -0
- package/src/components/selection.css +25 -0
- package/src/components/skeleton.css +7 -0
- package/src/components/slider.css +12 -0
- package/src/components/spinner.css +6 -0
- package/src/components/states.css +8 -0
- package/src/components/stepper.css +21 -0
- package/src/components/table.css +55 -0
- package/src/components/tabs.css +10 -0
- package/src/components/timeline.css +12 -0
- package/src/components/timepicker.css +22 -0
- package/src/components/toast.css +17 -0
- package/src/components/tooltip.css +6 -0
- package/src/components/tree.css +21 -0
- package/src/freeday-autocomplete.js +135 -0
- package/src/freeday-breakpoint.js +51 -0
- package/src/freeday-carousel.js +111 -0
- package/src/freeday-cascade.js +256 -0
- package/src/freeday-cfl.js +213 -0
- package/src/freeday-chart.js +403 -0
- package/src/freeday-chip.js +83 -0
- package/src/freeday-datepicker.js +321 -0
- package/src/freeday-datetime.js +59 -0
- package/src/freeday-drawer.js +43 -0
- package/src/freeday-form.js +181 -0
- package/src/freeday-mask.js +114 -0
- package/src/freeday-menu.js +93 -0
- package/src/freeday-popover.js +69 -0
- package/src/freeday-rating.js +50 -0
- package/src/freeday-select.js +194 -0
- package/src/freeday-slider.js +34 -0
- package/src/freeday-stepper.js +90 -0
- package/src/freeday-table.js +475 -0
- package/src/freeday-tabs.js +68 -0
- package/src/freeday-timepicker.js +180 -0
- package/src/freeday-toast.js +84 -0
- package/src/freeday-tree.js +94 -0
- package/src/freeday-upload.js +206 -0
- package/tokens/breakpoints.mjs +4 -0
- package/tokens/tokens.json +123 -0
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
|
+
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
6
|
+
[](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
|
+
}
|