@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,174 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch, type ComputedRef, type Ref } from 'vue';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A controlled Vue 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. A real `v-model` in place of the enhancer's
8
+ // DOM mutation, so Vue owns the input. Filtering mirrors the enhancer exactly (case-insensitive
9
+ // substring on the trimmed query); pass server-filtered `options` and it stays a no-op re-filter.
10
+ // `select` is emitted only on commit (the enhancer's fdy-autocomplete-select event).
11
+
12
+ const props = defineProps<{
13
+ modelValue: string;
14
+ options: ReadonlyArray<string>;
15
+ emptyText?: string;
16
+ placeholder?: string;
17
+ id?: string;
18
+ ariaLabel?: string;
19
+ ariaLabelledby?: string;
20
+ disabled?: boolean;
21
+ invalid?: boolean;
22
+ describedby?: string;
23
+ }>();
24
+
25
+ const emit = defineEmits<{
26
+ 'update:modelValue': [value: string];
27
+ select: [value: string];
28
+ }>();
29
+
30
+ const baseId: string = useId();
31
+ const inputId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-input`);
32
+ const listboxId: string = `${baseId}-listbox`;
33
+ function optionId(index: number): string {
34
+ return `${baseId}-opt-${index}`;
35
+ }
36
+
37
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
38
+ const inputEl: Ref<HTMLInputElement | null> = ref(null);
39
+ const listboxEl: Ref<HTMLUListElement | null> = ref(null);
40
+ const open: Ref<boolean> = ref(false);
41
+ const active: Ref<number> = ref(-1);
42
+
43
+ // Render the listbox in the top layer so it escapes any ancestor overflow clip.
44
+ usePopover(listboxEl, inputEl, open);
45
+
46
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
47
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
48
+
49
+ const filtered: ComputedRef<string[]> = computed((): string[] => {
50
+ const q: string = props.modelValue.trim().toLowerCase();
51
+ return q === '' ? props.options.slice() : props.options.filter((o: string): boolean => o.toLowerCase().includes(q));
52
+ });
53
+
54
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
55
+ open.value && active.value >= 0 && active.value < filtered.value.length ? optionId(active.value) : undefined,
56
+ );
57
+
58
+ function openList(): void {
59
+ if (!isDisabled.value) open.value = true;
60
+ }
61
+ function closeList(): void {
62
+ open.value = false;
63
+ active.value = -1;
64
+ }
65
+ function choose(label: string): void {
66
+ emit('update:modelValue', label);
67
+ emit('select', label);
68
+ closeList();
69
+ inputEl.value?.focus();
70
+ }
71
+ function onInput(e: Event): void {
72
+ const target: HTMLInputElement = e.target as HTMLInputElement;
73
+ emit('update:modelValue', target.value);
74
+ active.value = -1;
75
+ openList();
76
+ }
77
+
78
+ function onKeydown(e: KeyboardEvent): void {
79
+ if (isDisabled.value) return;
80
+ const len: number = filtered.value.length;
81
+ switch (e.key) {
82
+ case 'ArrowDown':
83
+ e.preventDefault();
84
+ if (!open.value) { openList(); active.value = len > 0 ? 0 : -1; }
85
+ else active.value = active.value + 1 < len ? active.value + 1 : 0;
86
+ break;
87
+ case 'ArrowUp':
88
+ e.preventDefault();
89
+ if (!open.value) { openList(); active.value = len - 1; }
90
+ else active.value = active.value - 1 >= 0 ? active.value - 1 : len - 1;
91
+ break;
92
+ case 'Enter':
93
+ if (open.value && active.value >= 0 && active.value < len) { e.preventDefault(); choose(filtered.value[active.value]); }
94
+ break;
95
+ case 'Escape':
96
+ if (open.value) { e.preventDefault(); closeList(); }
97
+ break;
98
+ case 'Tab':
99
+ if (open.value) closeList();
100
+ break;
101
+ default:
102
+ break;
103
+ }
104
+ }
105
+
106
+ // Close when a pointer lands outside the whole component.
107
+ function onDocPointerDown(e: MouseEvent): void {
108
+ const target: EventTarget | null = e.target;
109
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) closeList();
110
+ }
111
+ onMounted((): void => {
112
+ document.addEventListener('mousedown', onDocPointerDown);
113
+ });
114
+ onBeforeUnmount((): void => {
115
+ document.removeEventListener('mousedown', onDocPointerDown);
116
+ });
117
+
118
+ // Keep the highlighted option in view.
119
+ watch(active, async (i: number): Promise<void> => {
120
+ if (!open.value || i < 0) return;
121
+ await nextTick();
122
+ const items: NodeListOf<Element> | undefined = listboxEl.value?.querySelectorAll('[role="option"]');
123
+ const el: Element | undefined = items?.[i];
124
+ if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' });
125
+ });
126
+ </script>
127
+
128
+ <template>
129
+ <div ref="rootEl" class="fdy-autocomplete" @keydown="onKeydown">
130
+ <input
131
+ :id="inputId"
132
+ ref="inputEl"
133
+ :class="isInvalid ? 'fdy-input fdy-input--error' : 'fdy-input'"
134
+ type="text"
135
+ role="combobox"
136
+ :aria-expanded="open"
137
+ aria-autocomplete="list"
138
+ :aria-controls="listboxId"
139
+ :aria-activedescendant="activeDescendant"
140
+ :aria-label="ariaLabel"
141
+ :aria-labelledby="ariaLabelledby"
142
+ :aria-invalid="isInvalid ? 'true' : undefined"
143
+ :aria-describedby="describedby"
144
+ autocomplete="off"
145
+ :placeholder="placeholder"
146
+ :disabled="isDisabled"
147
+ :value="modelValue"
148
+ @input="onInput"
149
+ @focus="openList"
150
+ >
151
+ <ul
152
+ :id="listboxId"
153
+ ref="listboxEl"
154
+ class="fdy-autocomplete__listbox"
155
+ role="listbox"
156
+ :aria-label="ariaLabel"
157
+ popover="manual"
158
+ :hidden="!open"
159
+ >
160
+ <li
161
+ v-for="(opt, i) in filtered"
162
+ :id="optionId(i)"
163
+ :key="opt"
164
+ :class="i === active ? 'fdy-autocomplete__option is-highlighted' : 'fdy-autocomplete__option'"
165
+ role="option"
166
+ :aria-selected="opt === modelValue"
167
+ @mousedown.prevent
168
+ @mousemove="active = i"
169
+ @click="choose(opt)"
170
+ >{{ opt }}</li>
171
+ <li v-if="filtered.length === 0" class="fdy-autocomplete__empty">{{ emptyText ?? 'Tak ada hasil.' }}</li>
172
+ </ul>
173
+ </div>
174
+ </template>
@@ -0,0 +1,241 @@
1
+ <script lang="ts">
2
+ export interface CascadeNode {
3
+ label: string;
4
+ value: string;
5
+ /** Present = branch (drills in); absent = leaf (selectable). */
6
+ children?: ReadonlyArray<CascadeNode>;
7
+ }
8
+
9
+ // Depth-first search for the stack of nodes leading to a leaf value.
10
+ function pathTo(nodes: ReadonlyArray<CascadeNode>, value: string, trail: CascadeNode[]): CascadeNode[] | null {
11
+ for (const node of nodes) {
12
+ const here: CascadeNode[] = trail.concat([node]);
13
+ if (node.children === undefined && node.value === value) return here;
14
+ if (node.children !== undefined) {
15
+ const found: CascadeNode[] | null = pathTo(node.children, value, here);
16
+ if (found !== null) return found;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ </script>
22
+
23
+ <script setup lang="ts">
24
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch, type ComputedRef, type Ref } from 'vue';
25
+ import { usePopover } from '../usePopover';
26
+
27
+ // A controlled Vue port of freeday's cascade select (src/freeday-cascade.js + cascade.css):
28
+ // a hierarchical drill-down picker showing one level at a time — branches drill in, a back
29
+ // control ascends, a leaf selects and the value is the leaf's value (the display is the full
30
+ // path). The enhancer's data model is a hidden nested <ul>; here it is a typed tree, which is
31
+ // what a framework app actually has. A real `v-model` in place of the DOM mutation.
32
+
33
+ const props = defineProps<{
34
+ /** The selected LEAF value ('' = nothing selected). */
35
+ modelValue: string;
36
+ options: ReadonlyArray<CascadeNode>;
37
+ /** Path separator in the display, default " / " (matches the enhancer). */
38
+ separator?: string;
39
+ placeholder?: string;
40
+ /** Accessible name for the trigger + listbox (the enhancer's data-label). */
41
+ label?: string;
42
+ id?: string;
43
+ ariaLabelledby?: string;
44
+ disabled?: boolean;
45
+ invalid?: boolean;
46
+ describedby?: string;
47
+ }>();
48
+
49
+ const emit = defineEmits<{
50
+ 'update:modelValue': [value: string];
51
+ /** Fired when a leaf is picked; `labels` is the full path (root → leaf). */
52
+ change: [value: string, labels: string[]];
53
+ }>();
54
+
55
+ const baseId: string = useId();
56
+ const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
57
+ const listId: string = `${baseId}-list`;
58
+ function optionId(index: number): string {
59
+ return `${baseId}-opt-${index}`;
60
+ }
61
+
62
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
63
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
64
+ const panelEl: Ref<HTMLDivElement | null> = ref(null);
65
+ const listEl: Ref<HTMLUListElement | null> = ref(null);
66
+
67
+ const open: Ref<boolean> = ref(false);
68
+ const stack: Ref<CascadeNode[]> = ref([]) as Ref<CascadeNode[]>;
69
+ const active: Ref<number> = ref(-1);
70
+
71
+ usePopover(panelEl, triggerEl, open);
72
+
73
+ const sep: ComputedRef<string> = computed((): string => props.separator ?? ' / ');
74
+ const name: ComputedRef<string> = computed((): string => props.label ?? 'Pilih');
75
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
76
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
77
+
78
+ const current: ComputedRef<ReadonlyArray<CascadeNode>> = computed((): ReadonlyArray<CascadeNode> =>
79
+ stack.value.length === 0 ? props.options : (stack.value[stack.value.length - 1].children ?? []),
80
+ );
81
+ const selectedTrail: ComputedRef<CascadeNode[] | null> = computed((): CascadeNode[] | null =>
82
+ props.modelValue === '' ? null : pathTo(props.options, props.modelValue, []),
83
+ );
84
+ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => selectedTrail.value === null);
85
+ const displayValue: ComputedRef<string> = computed((): string =>
86
+ selectedTrail.value !== null
87
+ ? selectedTrail.value.map((n: CascadeNode): string => n.label).join(sep.value)
88
+ : (props.placeholder ?? 'Pilih…'),
89
+ );
90
+ const crumb: ComputedRef<string> = computed((): string =>
91
+ stack.value.length > 0 ? stack.value.map((n: CascadeNode): string => n.label).join(sep.value) : name.value,
92
+ );
93
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
94
+ active.value >= 0 ? optionId(active.value) : undefined,
95
+ );
96
+
97
+ function openPanel(): void {
98
+ if (isDisabled.value) return;
99
+ // Re-open at the selected leaf's level for quick re-selection (matches the enhancer).
100
+ const trail: CascadeNode[] | null = selectedTrail.value;
101
+ stack.value = trail !== null && trail.length > 1 ? trail.slice(0, -1) : [];
102
+ active.value = 0;
103
+ open.value = true;
104
+ }
105
+ function closePanel(returnFocus: boolean): void {
106
+ open.value = false;
107
+ active.value = -1;
108
+ if (returnFocus) triggerEl.value?.focus();
109
+ }
110
+ function drill(index: number): void {
111
+ const node: CascadeNode | undefined = current.value[index];
112
+ if (node === undefined || node.children === undefined) return;
113
+ stack.value = stack.value.concat([node]);
114
+ active.value = 0;
115
+ }
116
+ function ascend(): void {
117
+ if (stack.value.length === 0) return;
118
+ stack.value = stack.value.slice(0, -1);
119
+ active.value = 0;
120
+ }
121
+ function activate(index: number): void {
122
+ const node: CascadeNode | undefined = current.value[index];
123
+ if (node === undefined) return;
124
+ if (node.children !== undefined) { drill(index); return; }
125
+ const labels: string[] = stack.value.map((n: CascadeNode): string => n.label).concat([node.label]);
126
+ emit('update:modelValue', node.value);
127
+ emit('change', node.value, labels);
128
+ closePanel(true);
129
+ }
130
+
131
+ function onListKeydown(e: KeyboardEvent): void {
132
+ const len: number = current.value.length;
133
+ switch (e.key) {
134
+ case 'ArrowDown': e.preventDefault(); active.value = Math.min(len - 1, active.value + 1); break;
135
+ case 'ArrowUp': e.preventDefault(); active.value = Math.max(0, active.value - 1); break;
136
+ case 'Home': e.preventDefault(); active.value = 0; break;
137
+ case 'End': e.preventDefault(); active.value = len - 1; break;
138
+ case 'ArrowRight':
139
+ case 'Enter':
140
+ case ' ': e.preventDefault(); if (active.value >= 0) activate(active.value); break;
141
+ case 'ArrowLeft':
142
+ case 'Backspace': e.preventDefault(); ascend(); break;
143
+ case 'Escape': e.preventDefault(); closePanel(true); break;
144
+ case 'Tab': closePanel(false); break;
145
+ default: break;
146
+ }
147
+ }
148
+ function onTriggerKeydown(e: KeyboardEvent): void {
149
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); }
150
+ }
151
+
152
+ // Move focus into the list when the panel opens (the enhancer does list.focus()).
153
+ watch(open, async (isOpen: boolean): Promise<void> => {
154
+ if (!isOpen) return;
155
+ await nextTick();
156
+ listEl.value?.focus();
157
+ });
158
+
159
+ // Keep the active option in view.
160
+ watch([active, stack], async (): Promise<void> => {
161
+ if (!open.value || active.value < 0) return;
162
+ await nextTick();
163
+ const items: NodeListOf<Element> | undefined = listEl.value?.querySelectorAll('[role="option"]');
164
+ const el: Element | undefined = items?.[active.value];
165
+ if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' });
166
+ });
167
+
168
+ // Close when a pointer lands outside the whole component.
169
+ function onDocPointerDown(e: MouseEvent): void {
170
+ const target: EventTarget | null = e.target;
171
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) closePanel(false);
172
+ }
173
+ onMounted((): void => {
174
+ document.addEventListener('mousedown', onDocPointerDown);
175
+ });
176
+ onBeforeUnmount((): void => {
177
+ document.removeEventListener('mousedown', onDocPointerDown);
178
+ });
179
+ </script>
180
+
181
+ <template>
182
+ <div ref="rootEl" :class="isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'">
183
+ <button
184
+ :id="triggerId"
185
+ ref="triggerEl"
186
+ type="button"
187
+ :class="open ? 'fdy-cascade__trigger is-open' : 'fdy-cascade__trigger'"
188
+ aria-haspopup="true"
189
+ :aria-expanded="open"
190
+ :aria-label="ariaLabelledby === undefined ? name : undefined"
191
+ :aria-labelledby="ariaLabelledby"
192
+ :aria-invalid="isInvalid ? 'true' : undefined"
193
+ :aria-describedby="describedby"
194
+ :disabled="isDisabled"
195
+ @click="open ? closePanel(true) : openPanel()"
196
+ @keydown="onTriggerKeydown"
197
+ >
198
+ <span :class="isPlaceholder ? 'fdy-cascade__value fdy-cascade__value--placeholder' : 'fdy-cascade__value'">{{ displayValue }}</span>
199
+ </button>
200
+
201
+ <div ref="panelEl" class="fdy-cascade__panel" popover="manual" :hidden="!open">
202
+ <div class="fdy-cascade__head">
203
+ <button
204
+ type="button"
205
+ class="fdy-cascade__back"
206
+ aria-label="Kembali satu tingkat"
207
+ :hidden="stack.length === 0"
208
+ @click="ascend(); listEl?.focus()"
209
+ >
210
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
211
+ </button>
212
+ <span class="fdy-cascade__crumb" aria-live="polite">{{ crumb }}</span>
213
+ </div>
214
+ <ul
215
+ :id="listId"
216
+ ref="listEl"
217
+ class="fdy-cascade__list"
218
+ role="listbox"
219
+ :aria-label="name"
220
+ tabindex="-1"
221
+ :aria-activedescendant="activeDescendant"
222
+ @keydown="onListKeydown"
223
+ >
224
+ <li
225
+ v-for="(node, i) in current"
226
+ :id="optionId(i)"
227
+ :key="node.value"
228
+ role="option"
229
+ :class="i === active ? 'fdy-cascade__opt is-active' : 'fdy-cascade__opt'"
230
+ :aria-selected="node.children === undefined && node.value === modelValue"
231
+ :aria-label="node.children !== undefined ? `${node.label}, submenu` : undefined"
232
+ @mousemove="active = i"
233
+ @click="activate(i)"
234
+ >
235
+ <span class="fdy-cascade__opt-label">{{ node.label }}</span>
236
+ <span v-if="node.children !== undefined" class="fdy-cascade__opt-arrow" aria-hidden="true" />
237
+ </li>
238
+ </ul>
239
+ </div>
240
+ </div>
241
+ </template>