@fivem-nui/svelte-components 0.0.0-stage → 1.0.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/README.md +101 -2
- package/dist/components/actions/NuiButton.svelte +292 -0
- package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
- package/dist/components/data/NuiBadge.svelte +277 -0
- package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
- package/dist/components/data/NuiCodeBlock.svelte +256 -0
- package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
- package/dist/components/data/NuiList.svelte +341 -0
- package/dist/components/data/NuiList.svelte.d.ts +52 -0
- package/dist/components/data/NuiProgressBar.svelte +264 -0
- package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
- package/dist/components/data/NuiRichText.svelte +260 -0
- package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
- package/dist/components/data/NuiTable.svelte +470 -0
- package/dist/components/data/NuiTable.svelte.d.ts +53 -0
- package/dist/components/data/chart/NuiChart.svelte +384 -0
- package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
- package/dist/components/data/chart/color.d.ts +9 -0
- package/dist/components/data/chart/color.js +78 -0
- package/dist/components/data/chart/index.d.ts +10 -0
- package/dist/components/data/chart/index.js +10 -0
- package/dist/components/data/chart/registry.d.ts +14 -0
- package/dist/components/data/chart/registry.js +53 -0
- package/dist/components/data/chart/renderers/bar.d.ts +8 -0
- package/dist/components/data/chart/renderers/bar.js +79 -0
- package/dist/components/data/chart/renderers/base.d.ts +18 -0
- package/dist/components/data/chart/renderers/base.js +10 -0
- package/dist/components/data/chart/renderers/line.d.ts +8 -0
- package/dist/components/data/chart/renderers/line.js +89 -0
- package/dist/components/data/chart/renderers/pie.d.ts +10 -0
- package/dist/components/data/chart/renderers/pie.js +55 -0
- package/dist/components/data/chart/scale.d.ts +22 -0
- package/dist/components/data/chart/scale.js +101 -0
- package/dist/components/data/chart/types.d.ts +84 -0
- package/dist/components/data/chart/types.js +1 -0
- package/dist/components/data/chart/validator.d.ts +10 -0
- package/dist/components/data/chart/validator.js +82 -0
- package/dist/components/forms/NuiCheckbox.svelte +218 -0
- package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
- package/dist/components/forms/NuiDatepicker.svelte +755 -0
- package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
- package/dist/components/forms/NuiFileInput.svelte +1047 -0
- package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
- package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
- package/dist/components/forms/NuiFileInput.types.js +1 -0
- package/dist/components/forms/NuiForm.svelte +391 -0
- package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
- package/dist/components/forms/NuiInput.svelte +296 -0
- package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
- package/dist/components/forms/NuiRadioGroup.svelte +238 -0
- package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
- package/dist/components/forms/NuiSelect.svelte +819 -0
- package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
- package/dist/components/layout/NuiCard.svelte +314 -0
- package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
- package/dist/components/layout/NuiCardGrid.svelte +98 -0
- package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
- package/dist/components/layout/NuiFlex.svelte +121 -0
- package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
- package/dist/components/layout/NuiGrid.svelte +66 -0
- package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
- package/dist/components/layout/NuiStack.svelte +138 -0
- package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiAccordion.svelte +323 -0
- package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
- package/dist/components/navigation/NuiMenu.svelte +596 -0
- package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
- package/dist/components/navigation/NuiPagination.svelte +306 -0
- package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
- package/dist/components/navigation/NuiSidebar.svelte +186 -0
- package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiTabs.svelte +249 -0
- package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
- package/dist/components/overlay/NuiDialog.svelte +118 -0
- package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
- package/dist/components/overlay/NuiModal.svelte +992 -0
- package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
- package/dist/components/overlay/NuiTooltip.svelte +159 -0
- package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
- package/dist/composables/useClickOutside.d.ts +43 -0
- package/dist/composables/useClickOutside.js +73 -0
- package/dist/composables/useFormValidation.d.ts +23 -0
- package/dist/composables/useFormValidation.js +72 -0
- package/dist/composables/useIdle.svelte.d.ts +18 -0
- package/dist/composables/useIdle.svelte.js +144 -0
- package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
- package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
- package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
- package/dist/composables/useLocalStorage.svelte.js +105 -0
- package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
- package/dist/composables/useMediaQuery.svelte.js +80 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +42 -0
- package/package.json +58 -3
- package/src/components/actions/NuiButton.svelte +292 -0
- package/src/components/data/NuiBadge.svelte +277 -0
- package/src/components/data/NuiCodeBlock.svelte +256 -0
- package/src/components/data/NuiList.svelte +341 -0
- package/src/components/data/NuiProgressBar.svelte +264 -0
- package/src/components/data/NuiRichText.svelte +260 -0
- package/src/components/data/NuiTable.svelte +470 -0
- package/src/components/data/chart/NuiChart.svelte +384 -0
- package/src/components/data/chart/color.ts +83 -0
- package/src/components/data/chart/index.ts +10 -0
- package/src/components/data/chart/registry.ts +71 -0
- package/src/components/data/chart/renderers/bar.ts +90 -0
- package/src/components/data/chart/renderers/base.ts +32 -0
- package/src/components/data/chart/renderers/line.ts +99 -0
- package/src/components/data/chart/renderers/pie.ts +66 -0
- package/src/components/data/chart/scale.ts +139 -0
- package/src/components/data/chart/types.ts +98 -0
- package/src/components/data/chart/validator.ts +98 -0
- package/src/components/forms/NuiCheckbox.svelte +218 -0
- package/src/components/forms/NuiDatepicker.svelte +755 -0
- package/src/components/forms/NuiFileInput.svelte +1047 -0
- package/src/components/forms/NuiFileInput.types.ts +29 -0
- package/src/components/forms/NuiForm.svelte +391 -0
- package/src/components/forms/NuiInput.svelte +296 -0
- package/src/components/forms/NuiRadioGroup.svelte +238 -0
- package/src/components/forms/NuiSelect.svelte +819 -0
- package/src/components/layout/NuiCard.svelte +314 -0
- package/src/components/layout/NuiCardGrid.svelte +98 -0
- package/src/components/layout/NuiFlex.svelte +121 -0
- package/src/components/layout/NuiGrid.svelte +66 -0
- package/src/components/layout/NuiStack.svelte +138 -0
- package/src/components/navigation/NuiAccordion.svelte +323 -0
- package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/src/components/navigation/NuiMenu.svelte +596 -0
- package/src/components/navigation/NuiPagination.svelte +306 -0
- package/src/components/navigation/NuiSidebar.svelte +186 -0
- package/src/components/navigation/NuiTabs.svelte +249 -0
- package/src/components/overlay/NuiDialog.svelte +118 -0
- package/src/components/overlay/NuiModal.svelte +992 -0
- package/src/components/overlay/NuiTooltip.svelte +159 -0
- package/src/composables/useClickOutside.ts +132 -0
- package/src/composables/useFormValidation.ts +105 -0
- package/src/composables/useIdle.svelte.ts +191 -0
- package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
- package/src/composables/useLocalStorage.svelte.ts +137 -0
- package/src/composables/useMediaQuery.svelte.ts +102 -0
- package/src/index.ts +66 -0
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface NuiSelectOption {
|
|
3
|
+
/** Unique value identifying this option. */
|
|
4
|
+
value: string;
|
|
5
|
+
/** Human-readable text label. */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Whether this individual option is disabled. */
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import { nui } from "@fivem-nui/svelte";
|
|
14
|
+
import type { SvelteNuiClient } from "@fivem-nui/svelte";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
/** NUI event name emitted when selection changes or is cleared. */
|
|
18
|
+
eventName?: string;
|
|
19
|
+
/** SvelteNuiClient instance used to dispatch NUI events. */
|
|
20
|
+
client?: SvelteNuiClient;
|
|
21
|
+
/**
|
|
22
|
+
* Current selection value (two-way bindable):
|
|
23
|
+
* - In single-select mode (`multiple={false}`): a plain string containing the selected option's `value` (e.g. `'item_1'`), or `''` if empty.
|
|
24
|
+
* - In multi-select mode (`multiple={true}`): a serialized JSON array string of selected option values (e.g. `'["item_1", "item_2"]'`), or `'[]'` / `''` if empty.
|
|
25
|
+
*
|
|
26
|
+
* Note: In multi-select mode, the underlying `<input type="hidden" {name} {value} />` submits this JSON string.
|
|
27
|
+
* When emitting the NUI event (`eventName`), a parsed array (`value: string[]`) is provided in the event payload.
|
|
28
|
+
*/
|
|
29
|
+
value?: string;
|
|
30
|
+
/** Text label displayed above the select control. */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Placeholder text shown when no option is selected. Defaults to 'Select...'. */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Available select options. Each option's `value` must be unique. */
|
|
35
|
+
options?: NuiSelectOption[];
|
|
36
|
+
/** Whether the select control is disabled. */
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether multiple options can be selected simultaneously.
|
|
40
|
+
* When enabled, `value` binds to a serialized JSON array of selected strings (e.g. `'["opt1","opt2"]'`).
|
|
41
|
+
*/
|
|
42
|
+
multiple?: boolean;
|
|
43
|
+
/** Whether the search input is enabled in the dropdown panel. Defaults to true. */
|
|
44
|
+
searchable?: boolean;
|
|
45
|
+
/** Maximum CSS height for the dropdown panel (e.g. '200px', '15rem'). Defaults to '200px'. */
|
|
46
|
+
maxHeight?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Form input name attribute rendered on an underlying `<input type="hidden" {name} {value} />`.
|
|
49
|
+
* In single mode, submits the selected string value. In multi-select mode, submits the JSON array string.
|
|
50
|
+
*/
|
|
51
|
+
name?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const autoId = $props.id();
|
|
55
|
+
const selectId = `nui-select-${autoId}`;
|
|
56
|
+
const labelId = `${selectId}-label`;
|
|
57
|
+
const triggerId = `${selectId}-trigger`;
|
|
58
|
+
const listboxId = `${selectId}-listbox`;
|
|
59
|
+
const valueId = `${selectId}-val`;
|
|
60
|
+
|
|
61
|
+
let {
|
|
62
|
+
eventName = "",
|
|
63
|
+
client = nui,
|
|
64
|
+
value = $bindable(""),
|
|
65
|
+
label = "",
|
|
66
|
+
placeholder = "Select...",
|
|
67
|
+
options = [],
|
|
68
|
+
disabled = false,
|
|
69
|
+
multiple = false,
|
|
70
|
+
searchable = true,
|
|
71
|
+
maxHeight = "200px",
|
|
72
|
+
name = "",
|
|
73
|
+
}: Props = $props();
|
|
74
|
+
|
|
75
|
+
let open = $state(false);
|
|
76
|
+
let query = $state("");
|
|
77
|
+
let focusedIndex = $state(-1);
|
|
78
|
+
let containerEl: HTMLElement | null = $state(null);
|
|
79
|
+
let triggerButtonEl: HTMLButtonElement | null = $state(null);
|
|
80
|
+
let searchInputEl: HTMLInputElement | null = $state(null);
|
|
81
|
+
let listboxEl: HTMLElement | null = $state(null);
|
|
82
|
+
|
|
83
|
+
let previousOptions: NuiSelectOption[] | undefined;
|
|
84
|
+
let lastFocusedValue: string | undefined;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Safely normalizes the bound `value` string into a typed `string[]` for multiple selection.
|
|
88
|
+
* Handles valid JSON arrays, non-array JSON values, and malformed strings gracefully.
|
|
89
|
+
*/
|
|
90
|
+
const selected = $derived.by((): string[] => {
|
|
91
|
+
if (!multiple || !value) return [];
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(value);
|
|
94
|
+
return Array.isArray(parsed)
|
|
95
|
+
? parsed.filter((item): item is string => typeof item === "string")
|
|
96
|
+
: [];
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
|
103
|
+
|
|
104
|
+
const triggerLabel = $derived.by(() => {
|
|
105
|
+
if (multiple) {
|
|
106
|
+
return selected.length ? selected.map(labelOf).join(", ") : placeholder;
|
|
107
|
+
}
|
|
108
|
+
return value ? labelOf(value) : placeholder;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const visible = $derived.by(() => {
|
|
112
|
+
const term = query.trim().toLowerCase();
|
|
113
|
+
return term
|
|
114
|
+
? options.filter(
|
|
115
|
+
(o) => o.label.toLowerCase().includes(term) && !o.disabled,
|
|
116
|
+
)
|
|
117
|
+
: options;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const canClear = $derived(
|
|
121
|
+
!disabled && (multiple ? selected.length > 0 : value !== ""),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Computes the ID of the currently focused option for `aria-activedescendant`.
|
|
126
|
+
* Only returns an ID when focusedIndex points to an existing, non-disabled option.
|
|
127
|
+
*/
|
|
128
|
+
const activeDescendantId = $derived(
|
|
129
|
+
open &&
|
|
130
|
+
focusedIndex >= 0 &&
|
|
131
|
+
visible[focusedIndex] &&
|
|
132
|
+
!visible[focusedIndex].disabled
|
|
133
|
+
? `${listboxId}-opt-${focusedIndex}`
|
|
134
|
+
: undefined,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
$effect(() => {
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
for (const opt of options) {
|
|
140
|
+
if (seen.has(opt.value)) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[fivem-nui-components] NuiSelect: Duplicate option value '${opt.value}' detected. Option values must be unique.`,
|
|
143
|
+
);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
seen.add(opt.value);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Close dropdown and reset state when disabled state changes to true
|
|
151
|
+
$effect(() => {
|
|
152
|
+
if (disabled && open) {
|
|
153
|
+
open = false;
|
|
154
|
+
query = "";
|
|
155
|
+
focusedIndex = -1;
|
|
156
|
+
lastFocusedValue = undefined;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Focus appropriate element when dropdown opens
|
|
161
|
+
$effect(() => {
|
|
162
|
+
if (open) {
|
|
163
|
+
if (searchable && searchInputEl) {
|
|
164
|
+
searchInputEl.focus();
|
|
165
|
+
} else if (!searchable && listboxEl) {
|
|
166
|
+
listboxEl.focus();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Preserve active option when options array reference changes from parent while open
|
|
172
|
+
$effect(() => {
|
|
173
|
+
const currentOptions = options;
|
|
174
|
+
if (previousOptions !== undefined && currentOptions !== previousOptions) {
|
|
175
|
+
if (open) {
|
|
176
|
+
if (lastFocusedValue !== undefined) {
|
|
177
|
+
const newIdx = visible.findIndex(
|
|
178
|
+
(o) => o.value === lastFocusedValue && !o.disabled,
|
|
179
|
+
);
|
|
180
|
+
if (newIdx >= 0) {
|
|
181
|
+
focusedIndex = newIdx;
|
|
182
|
+
lastFocusedValue = visible[newIdx]?.value;
|
|
183
|
+
previousOptions = currentOptions;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
focusFirst();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
previousOptions = currentOptions;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Scroll focused option into view when navigating via keyboard
|
|
194
|
+
$effect(() => {
|
|
195
|
+
if (open && focusedIndex >= 0 && listboxEl) {
|
|
196
|
+
const optEl = listboxEl.querySelector(`[data-index="${focusedIndex}"]`);
|
|
197
|
+
if (
|
|
198
|
+
optEl &&
|
|
199
|
+
typeof (optEl as HTMLElement).scrollIntoView === "function"
|
|
200
|
+
) {
|
|
201
|
+
(optEl as HTMLElement).scrollIntoView({ block: "nearest" });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Safely sends an event via the NUI client, catching synchronous exceptions
|
|
208
|
+
* and handling any returned Promise rejection asynchronously.
|
|
209
|
+
*/
|
|
210
|
+
function safeSend(event: string, data: unknown) {
|
|
211
|
+
if (!event) return;
|
|
212
|
+
try {
|
|
213
|
+
const res = client.send(event, data);
|
|
214
|
+
void Promise.resolve(res).catch((err: unknown) => {
|
|
215
|
+
console.error(
|
|
216
|
+
`[fivem-nui-components] NuiSelect: Async error sending event '${event}':`,
|
|
217
|
+
err,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(
|
|
222
|
+
`[fivem-nui-components] NuiSelect: Failed to send event '${event}':`,
|
|
223
|
+
err,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function focusFirst() {
|
|
229
|
+
const idx = visible.findIndex((o) => !o.disabled);
|
|
230
|
+
focusedIndex = idx;
|
|
231
|
+
lastFocusedValue = idx >= 0 ? visible[idx]?.value : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function focusLast() {
|
|
235
|
+
for (let i = visible.length - 1; i >= 0; i--) {
|
|
236
|
+
if (!visible[i].disabled) {
|
|
237
|
+
focusedIndex = i;
|
|
238
|
+
lastFocusedValue = visible[i]?.value;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
focusedIndex = -1;
|
|
243
|
+
lastFocusedValue = undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Finds the next enabled option index starting from current focusedIndex in direction `delta` (1 or -1).
|
|
248
|
+
* Guarantees that the resulting focusedIndex is either a valid enabled option or -1 (if none enabled).
|
|
249
|
+
*/
|
|
250
|
+
function moveFocus(delta: number) {
|
|
251
|
+
const total = visible.length;
|
|
252
|
+
if (total === 0 || !visible.some((o) => !o.disabled)) {
|
|
253
|
+
focusedIndex = -1;
|
|
254
|
+
lastFocusedValue = undefined;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let next = focusedIndex;
|
|
259
|
+
if (next < 0 || next >= total) {
|
|
260
|
+
next = delta > 0 ? -1 : total;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (let step = 0; step < total; step++) {
|
|
264
|
+
next = (next + delta + total) % total;
|
|
265
|
+
if (!visible[next].disabled) {
|
|
266
|
+
focusedIndex = next;
|
|
267
|
+
lastFocusedValue = visible[next]?.value;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
focusedIndex = -1;
|
|
272
|
+
lastFocusedValue = undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function selectFocusedOption(): boolean {
|
|
276
|
+
if (focusedIndex >= 0 && focusedIndex < visible.length) {
|
|
277
|
+
const option = visible[focusedIndex];
|
|
278
|
+
if (option && !option.disabled) {
|
|
279
|
+
selectOption(option);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function openDropdown() {
|
|
287
|
+
if (disabled) return;
|
|
288
|
+
open = true;
|
|
289
|
+
|
|
290
|
+
// Pre-focus the currently selected option if visible and enabled
|
|
291
|
+
if (!multiple && value) {
|
|
292
|
+
const selectedIdx = visible.findIndex(
|
|
293
|
+
(o) => o.value === value && !o.disabled,
|
|
294
|
+
);
|
|
295
|
+
if (selectedIdx >= 0) {
|
|
296
|
+
focusedIndex = selectedIdx;
|
|
297
|
+
lastFocusedValue = visible[selectedIdx]?.value;
|
|
298
|
+
} else {
|
|
299
|
+
focusFirst();
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
focusFirst();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function closeDropdown(restoreFocus = true) {
|
|
307
|
+
open = false;
|
|
308
|
+
query = "";
|
|
309
|
+
focusedIndex = -1;
|
|
310
|
+
lastFocusedValue = undefined;
|
|
311
|
+
if (restoreFocus && triggerButtonEl) {
|
|
312
|
+
triggerButtonEl.focus();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toggle() {
|
|
317
|
+
if (disabled) return;
|
|
318
|
+
if (open) {
|
|
319
|
+
closeDropdown(false);
|
|
320
|
+
} else {
|
|
321
|
+
openDropdown();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function selectOption(option: NuiSelectOption) {
|
|
326
|
+
if (disabled || option.disabled) return;
|
|
327
|
+
|
|
328
|
+
if (multiple) {
|
|
329
|
+
const next = selected.includes(option.value)
|
|
330
|
+
? selected.filter((v) => v !== option.value)
|
|
331
|
+
: [...selected, option.value];
|
|
332
|
+
value = JSON.stringify(next);
|
|
333
|
+
safeSend(eventName, {
|
|
334
|
+
value: next,
|
|
335
|
+
option: { value: option.value, label: option.label },
|
|
336
|
+
multiple: true,
|
|
337
|
+
});
|
|
338
|
+
} else {
|
|
339
|
+
value = option.value;
|
|
340
|
+
closeDropdown(true);
|
|
341
|
+
safeSend(eventName, {
|
|
342
|
+
value: option.value,
|
|
343
|
+
label: option.label,
|
|
344
|
+
multiple: false,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function clear(event: MouseEvent) {
|
|
350
|
+
event.stopPropagation();
|
|
351
|
+
if (disabled) return;
|
|
352
|
+
|
|
353
|
+
if (multiple) {
|
|
354
|
+
value = "[]";
|
|
355
|
+
safeSend(eventName, { value: [], multiple: true });
|
|
356
|
+
} else {
|
|
357
|
+
value = "";
|
|
358
|
+
safeSend(eventName, { value: "", label: "", multiple: false });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (triggerButtonEl) {
|
|
362
|
+
triggerButtonEl.focus();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function searchAction(node: HTMLInputElement) {
|
|
367
|
+
const onInput = () => {
|
|
368
|
+
query = node.value;
|
|
369
|
+
focusFirst();
|
|
370
|
+
};
|
|
371
|
+
node.addEventListener("input", onInput);
|
|
372
|
+
return {
|
|
373
|
+
destroy() {
|
|
374
|
+
node.removeEventListener("input", onInput);
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleSearchInput(event: Event) {
|
|
380
|
+
query = (event.currentTarget as HTMLInputElement).value;
|
|
381
|
+
focusFirst();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function onTriggerKeydown(event: KeyboardEvent) {
|
|
385
|
+
if (disabled) return;
|
|
386
|
+
|
|
387
|
+
if (event.key === "ArrowDown") {
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
if (!open) {
|
|
390
|
+
openDropdown();
|
|
391
|
+
} else {
|
|
392
|
+
moveFocus(1);
|
|
393
|
+
}
|
|
394
|
+
} else if (event.key === "ArrowUp") {
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
if (!open) {
|
|
397
|
+
openDropdown();
|
|
398
|
+
} else {
|
|
399
|
+
moveFocus(-1);
|
|
400
|
+
}
|
|
401
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
if (!open) {
|
|
404
|
+
openDropdown();
|
|
405
|
+
} else {
|
|
406
|
+
selectFocusedOption();
|
|
407
|
+
}
|
|
408
|
+
} else if (event.key === "Escape") {
|
|
409
|
+
if (open) {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
event.stopImmediatePropagation();
|
|
412
|
+
closeDropdown(true);
|
|
413
|
+
}
|
|
414
|
+
} else if (event.key === "Tab") {
|
|
415
|
+
if (open) {
|
|
416
|
+
closeDropdown(false);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function onSearchKeydown(event: KeyboardEvent) {
|
|
422
|
+
if (event.key === "ArrowDown") {
|
|
423
|
+
event.preventDefault();
|
|
424
|
+
moveFocus(1);
|
|
425
|
+
} else if (event.key === "ArrowUp") {
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
moveFocus(-1);
|
|
428
|
+
} else if (event.key === "Home") {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
focusFirst();
|
|
431
|
+
} else if (event.key === "End") {
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
focusLast();
|
|
434
|
+
} else if (event.key === "Enter") {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
selectFocusedOption();
|
|
437
|
+
} else if (event.key === "Escape") {
|
|
438
|
+
event.preventDefault();
|
|
439
|
+
event.stopImmediatePropagation();
|
|
440
|
+
closeDropdown(true);
|
|
441
|
+
} else if (event.key === "Tab") {
|
|
442
|
+
closeDropdown(false);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function onListboxKeydown(event: KeyboardEvent) {
|
|
447
|
+
if (event.key === "ArrowDown") {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
moveFocus(1);
|
|
450
|
+
} else if (event.key === "ArrowUp") {
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
moveFocus(-1);
|
|
453
|
+
} else if (event.key === "Home") {
|
|
454
|
+
event.preventDefault();
|
|
455
|
+
focusFirst();
|
|
456
|
+
} else if (event.key === "End") {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
focusLast();
|
|
459
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
selectFocusedOption();
|
|
462
|
+
} else if (event.key === "Escape") {
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
event.stopImmediatePropagation();
|
|
465
|
+
closeDropdown(true);
|
|
466
|
+
} else if (event.key === "Tab") {
|
|
467
|
+
closeDropdown(false);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function handleWindowPointerDown(event: PointerEvent) {
|
|
472
|
+
if (open && containerEl && !containerEl.contains(event.target as Node)) {
|
|
473
|
+
closeDropdown(false);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
</script>
|
|
477
|
+
|
|
478
|
+
<svelte:window onpointerdown={handleWindowPointerDown} />
|
|
479
|
+
|
|
480
|
+
<div
|
|
481
|
+
class="nui-select"
|
|
482
|
+
class:is-open={open}
|
|
483
|
+
class:is-disabled={disabled}
|
|
484
|
+
class:has-clear={canClear}
|
|
485
|
+
bind:this={containerEl}
|
|
486
|
+
>
|
|
487
|
+
{#if label}
|
|
488
|
+
<span id={labelId} class="nui-select__label">{label}</span>
|
|
489
|
+
{/if}
|
|
490
|
+
|
|
491
|
+
<div class="nui-select__control">
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
id={triggerId}
|
|
495
|
+
class="nui-select__trigger"
|
|
496
|
+
bind:this={triggerButtonEl}
|
|
497
|
+
{disabled}
|
|
498
|
+
role={!searchable ? "combobox" : undefined}
|
|
499
|
+
aria-expanded={open}
|
|
500
|
+
aria-haspopup="listbox"
|
|
501
|
+
aria-controls={listboxId}
|
|
502
|
+
aria-labelledby={label ? `${labelId} ${valueId}` : valueId}
|
|
503
|
+
onclick={toggle}
|
|
504
|
+
onkeydown={onTriggerKeydown}
|
|
505
|
+
>
|
|
506
|
+
<span
|
|
507
|
+
id={valueId}
|
|
508
|
+
class="nui-select__value"
|
|
509
|
+
class:is-placeholder={!value || (multiple && !selected.length)}
|
|
510
|
+
>
|
|
511
|
+
{triggerLabel}
|
|
512
|
+
</span>
|
|
513
|
+
<span class="nui-select__arrow" aria-hidden="true"
|
|
514
|
+
>{open ? "▴" : "▾"}</span
|
|
515
|
+
>
|
|
516
|
+
</button>
|
|
517
|
+
|
|
518
|
+
{#if canClear}
|
|
519
|
+
<button
|
|
520
|
+
type="button"
|
|
521
|
+
class="nui-select__clear"
|
|
522
|
+
aria-label="Clear selection"
|
|
523
|
+
onclick={clear}>×</button
|
|
524
|
+
>
|
|
525
|
+
{/if}
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
{#if open}
|
|
529
|
+
<div class="nui-select__panel" style:max-height={maxHeight}>
|
|
530
|
+
{#if searchable}
|
|
531
|
+
<input
|
|
532
|
+
bind:this={searchInputEl}
|
|
533
|
+
use:searchAction
|
|
534
|
+
class="nui-select__search"
|
|
535
|
+
type="text"
|
|
536
|
+
role="combobox"
|
|
537
|
+
aria-autocomplete="list"
|
|
538
|
+
aria-expanded="true"
|
|
539
|
+
aria-controls={listboxId}
|
|
540
|
+
aria-activedescendant={activeDescendantId}
|
|
541
|
+
placeholder="Search..."
|
|
542
|
+
value={query}
|
|
543
|
+
aria-label={label ? `Search ${label}` : "Search options"}
|
|
544
|
+
onclick={(e) => e.stopPropagation()}
|
|
545
|
+
oninput={handleSearchInput}
|
|
546
|
+
onkeydown={onSearchKeydown}
|
|
547
|
+
/>
|
|
548
|
+
{/if}
|
|
549
|
+
<ul
|
|
550
|
+
id={listboxId}
|
|
551
|
+
class="nui-select__options"
|
|
552
|
+
role="listbox"
|
|
553
|
+
aria-multiselectable={multiple ? "true" : undefined}
|
|
554
|
+
aria-labelledby={label ? labelId : triggerId}
|
|
555
|
+
aria-activedescendant={!searchable ? activeDescendantId : undefined}
|
|
556
|
+
bind:this={listboxEl}
|
|
557
|
+
tabindex={!searchable ? 0 : -1}
|
|
558
|
+
onkeydown={!searchable ? onListboxKeydown : undefined}
|
|
559
|
+
>
|
|
560
|
+
{#each visible as option, idx (option.value)}
|
|
561
|
+
{@const isSelected = multiple
|
|
562
|
+
? selected.includes(option.value)
|
|
563
|
+
: value === option.value}
|
|
564
|
+
{@const isFocused = focusedIndex === idx}
|
|
565
|
+
<li
|
|
566
|
+
id={`${listboxId}-opt-${idx}`}
|
|
567
|
+
role="option"
|
|
568
|
+
class="nui-select__option"
|
|
569
|
+
class:is-selected={isSelected}
|
|
570
|
+
class:is-focused={isFocused}
|
|
571
|
+
class:is-disabled={option.disabled}
|
|
572
|
+
aria-selected={isSelected}
|
|
573
|
+
aria-disabled={option.disabled ? "true" : undefined}
|
|
574
|
+
data-index={idx}
|
|
575
|
+
onclick={() => selectOption(option)}
|
|
576
|
+
onkeydown={(e) => {
|
|
577
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
selectOption(option);
|
|
580
|
+
}
|
|
581
|
+
}}
|
|
582
|
+
onmouseenter={() => {
|
|
583
|
+
if (!option.disabled) {
|
|
584
|
+
focusedIndex = idx;
|
|
585
|
+
lastFocusedValue = option.value;
|
|
586
|
+
}
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
<span class="nui-select__option-text">{option.label}</span>
|
|
590
|
+
{#if multiple && isSelected}
|
|
591
|
+
<span class="nui-select__check" aria-hidden="true">✓</span>
|
|
592
|
+
{/if}
|
|
593
|
+
</li>
|
|
594
|
+
{:else}
|
|
595
|
+
<li class="nui-select__empty" role="presentation">
|
|
596
|
+
No results found
|
|
597
|
+
</li>
|
|
598
|
+
{/each}
|
|
599
|
+
</ul>
|
|
600
|
+
</div>
|
|
601
|
+
{/if}
|
|
602
|
+
|
|
603
|
+
{#if name}
|
|
604
|
+
<!-- Form integration: submits value string (plain string in single mode, serialized JSON array in multiple mode) -->
|
|
605
|
+
<input type="hidden" {name} {value} />
|
|
606
|
+
{/if}
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<style>
|
|
610
|
+
.nui-select {
|
|
611
|
+
position: relative;
|
|
612
|
+
width: 100%;
|
|
613
|
+
display: flex;
|
|
614
|
+
flex-direction: column;
|
|
615
|
+
gap: 0.3rem;
|
|
616
|
+
font-family: inherit;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.nui-select__label {
|
|
620
|
+
font-size: 0.8125rem;
|
|
621
|
+
font-weight: 600;
|
|
622
|
+
opacity: 0.75;
|
|
623
|
+
color: var(--nui-fg, #fafafa);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.nui-select__control {
|
|
627
|
+
position: relative;
|
|
628
|
+
display: flex;
|
|
629
|
+
align-items: center;
|
|
630
|
+
width: 100%;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.nui-select__trigger {
|
|
634
|
+
display: flex;
|
|
635
|
+
align-items: center;
|
|
636
|
+
justify-content: space-between;
|
|
637
|
+
gap: 0.5rem;
|
|
638
|
+
width: 100%;
|
|
639
|
+
padding: 0.5rem 0.75rem;
|
|
640
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
641
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
642
|
+
background: var(--nui-surface-alt, #101012);
|
|
643
|
+
color: var(--nui-fg, #fafafa);
|
|
644
|
+
font: inherit;
|
|
645
|
+
cursor: pointer;
|
|
646
|
+
box-sizing: border-box;
|
|
647
|
+
transition:
|
|
648
|
+
border-color 0.15s ease,
|
|
649
|
+
outline 0.15s ease;
|
|
650
|
+
text-align: left;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.nui-select.has-clear .nui-select__trigger {
|
|
654
|
+
padding-right: 2.25rem;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.nui-select__trigger:focus-visible {
|
|
658
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
659
|
+
outline-offset: 2px;
|
|
660
|
+
border-color: var(--nui-primary, #3b82f6);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.nui-select__trigger:disabled {
|
|
664
|
+
opacity: 0.5;
|
|
665
|
+
cursor: not-allowed;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.nui-select.is-open .nui-select__trigger {
|
|
669
|
+
border-color: var(--nui-primary, #3b82f6);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.nui-select__value {
|
|
673
|
+
flex: 1;
|
|
674
|
+
text-align: left;
|
|
675
|
+
overflow: hidden;
|
|
676
|
+
text-overflow: ellipsis;
|
|
677
|
+
white-space: nowrap;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.nui-select__value.is-placeholder {
|
|
681
|
+
opacity: 0.55;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.nui-select__clear {
|
|
685
|
+
position: absolute;
|
|
686
|
+
right: 1.75rem;
|
|
687
|
+
display: flex;
|
|
688
|
+
align-items: center;
|
|
689
|
+
justify-content: center;
|
|
690
|
+
border: 0;
|
|
691
|
+
border-radius: 0.2rem;
|
|
692
|
+
background: transparent;
|
|
693
|
+
color: inherit;
|
|
694
|
+
font-size: 1.1rem;
|
|
695
|
+
line-height: 1;
|
|
696
|
+
cursor: pointer;
|
|
697
|
+
opacity: 0.6;
|
|
698
|
+
padding: 0.2rem 0.35rem;
|
|
699
|
+
transition:
|
|
700
|
+
opacity 0.15s ease,
|
|
701
|
+
background 0.15s ease;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.nui-select__clear:hover {
|
|
705
|
+
opacity: 1;
|
|
706
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.1));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
.nui-select__clear:focus-visible {
|
|
710
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
711
|
+
outline-offset: 1px;
|
|
712
|
+
opacity: 1;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.nui-select__arrow {
|
|
716
|
+
flex: none;
|
|
717
|
+
font-size: 0.75rem;
|
|
718
|
+
opacity: 0.7;
|
|
719
|
+
pointer-events: none;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.nui-select__panel {
|
|
723
|
+
position: absolute;
|
|
724
|
+
top: calc(100% + 0.25rem);
|
|
725
|
+
left: 0;
|
|
726
|
+
right: 0;
|
|
727
|
+
z-index: var(--nui-z-dropdown, 100);
|
|
728
|
+
display: flex;
|
|
729
|
+
flex-direction: column;
|
|
730
|
+
overflow: hidden;
|
|
731
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
732
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
733
|
+
background: var(--nui-surface, #18181b);
|
|
734
|
+
box-shadow: 0 15px 35px rgb(0 0 0 / 0.4);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.nui-select__search {
|
|
738
|
+
margin: 0.375rem;
|
|
739
|
+
padding: 0.4rem 0.6rem;
|
|
740
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
741
|
+
border-radius: 0.25rem;
|
|
742
|
+
background: var(--nui-surface-alt, #101012);
|
|
743
|
+
color: inherit;
|
|
744
|
+
font: inherit;
|
|
745
|
+
box-sizing: border-box;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.nui-select__search:focus {
|
|
749
|
+
outline: 1px solid var(--nui-primary, #3b82f6);
|
|
750
|
+
border-color: var(--nui-primary, #3b82f6);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.nui-select__options {
|
|
754
|
+
list-style: none;
|
|
755
|
+
margin: 0;
|
|
756
|
+
padding: 0.25rem;
|
|
757
|
+
overflow-y: auto;
|
|
758
|
+
outline: none;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.nui-select__option {
|
|
762
|
+
display: flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
justify-content: space-between;
|
|
765
|
+
width: 100%;
|
|
766
|
+
padding: 0.45rem 0.6rem;
|
|
767
|
+
border-radius: 0.25rem;
|
|
768
|
+
background: transparent;
|
|
769
|
+
color: inherit;
|
|
770
|
+
font: inherit;
|
|
771
|
+
text-align: left;
|
|
772
|
+
cursor: pointer;
|
|
773
|
+
box-sizing: border-box;
|
|
774
|
+
user-select: none;
|
|
775
|
+
transition: background 0.1s ease;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.nui-select__option:hover:not(.is-disabled) {
|
|
779
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.06));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.nui-select__option.is-focused:not(.is-disabled) {
|
|
783
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.1));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.nui-select__option.is-selected {
|
|
787
|
+
background: var(--nui-primary, #3b82f6);
|
|
788
|
+
color: #fff;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.nui-select__option.is-selected.is-focused {
|
|
792
|
+
filter: brightness(1.1);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
.nui-select__option.is-disabled {
|
|
796
|
+
opacity: 0.4;
|
|
797
|
+
cursor: not-allowed;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.nui-select__option-text {
|
|
801
|
+
flex: 1;
|
|
802
|
+
overflow: hidden;
|
|
803
|
+
text-overflow: ellipsis;
|
|
804
|
+
white-space: nowrap;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.nui-select__check {
|
|
808
|
+
margin-left: 0.5rem;
|
|
809
|
+
font-weight: bold;
|
|
810
|
+
font-size: 0.85rem;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
.nui-select__empty {
|
|
814
|
+
padding: 0.6rem;
|
|
815
|
+
opacity: 0.55;
|
|
816
|
+
text-align: center;
|
|
817
|
+
font-size: 0.875rem;
|
|
818
|
+
}
|
|
819
|
+
</style>
|