svelte-multiselect 11.4.0 → 11.5.1
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/dist/CmdPalette.svelte +2 -2
- package/dist/CmdPalette.svelte.d.ts +10 -0
- package/dist/CopyButton.svelte +2 -9
- package/dist/FileDetails.svelte +23 -10
- package/dist/FileDetails.svelte.d.ts +1 -2
- package/dist/GitHubCorner.svelte +2 -2
- package/dist/MultiSelect.svelte +650 -165
- package/dist/MultiSelect.svelte.d.ts +2 -2
- package/dist/Nav.svelte +2 -2
- package/dist/PrevNext.svelte +6 -2
- package/dist/Toggle.svelte +8 -5
- package/dist/attachments.d.ts +18 -7
- package/dist/attachments.js +98 -0
- package/dist/icons.d.ts +8 -0
- package/dist/icons.js +8 -0
- package/dist/types.d.ts +47 -2
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +2 -0
- package/package.json +14 -12
- package/readme.md +138 -39
package/dist/MultiSelect.svelte
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<!-- eslint-disable-next-line @stylistic/quotes -- TS generics require string literals -->
|
|
2
2
|
<script lang="ts" generics="Option extends import('./types').Option">import { tick, untrack } from 'svelte';
|
|
3
3
|
import { flip } from 'svelte/animate';
|
|
4
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
4
5
|
import { highlight_matches } from './attachments';
|
|
5
6
|
import CircleSpinner from './CircleSpinner.svelte';
|
|
6
7
|
import Icon from './Icon.svelte';
|
|
7
|
-
import { fuzzy_match, get_label, get_style, is_object } from './utils';
|
|
8
|
+
import { fuzzy_match, get_label, get_style, has_group, is_object } from './utils';
|
|
8
9
|
import Wiggle from './Wiggle.svelte';
|
|
9
10
|
let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, keepSelectedInDropdown = false, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
|
|
10
11
|
if (!searchText)
|
|
@@ -17,32 +18,81 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
|
|
|
17
18
|
? (Array.isArray(value) ? value : [value])
|
|
18
19
|
: (options
|
|
19
20
|
?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
|
|
20
|
-
.slice(0, maxSelect ?? undefined) ?? [])), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, onselectAll, portal: portal_params = {},
|
|
21
|
+
.slice(0, maxSelect ?? undefined) ?? [])), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, onselectAll, onreorder, portal: portal_params = {},
|
|
21
22
|
// Select all feature
|
|
22
23
|
selectAllOption = false, liSelectAllClass = ``,
|
|
23
24
|
// Dynamic options loading
|
|
24
25
|
loadOptions,
|
|
25
26
|
// Animation parameters for selected options flip animation
|
|
26
|
-
selectedFlipParams = { duration: 100 },
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
selectedFlipParams = { duration: 100 },
|
|
28
|
+
// Option grouping feature
|
|
29
|
+
collapsibleGroups = false, collapsedGroups = $bindable(new Set()), groupSelectAll = false, ungroupedPosition = `first`, groupSortOrder = `none`, searchExpandsCollapsedGroups = false, searchMatchesGroups = false, keyboardExpandsCollapsedGroups = false, stickyGroupHeaders = false, liGroupHeaderClass = ``, liGroupHeaderStyle = null, groupHeader, ongroupToggle, oncollapseAll, onexpandAll, collapseAllGroups = $bindable(), expandAllGroups = $bindable(),
|
|
30
|
+
// Keyboard shortcuts for common actions
|
|
31
|
+
shortcuts = {}, ...rest } = $props();
|
|
32
|
+
// Generate unique IDs for ARIA associations (combobox pattern)
|
|
33
|
+
// Uses provided id prop or generates a random one using crypto API
|
|
34
|
+
const internal_id = $derived(id ?? `sms-${crypto.randomUUID().slice(0, 8)}`);
|
|
35
|
+
const listbox_id = $derived(`${internal_id}-listbox`);
|
|
36
|
+
// Parse shortcut string into modifier+key parts
|
|
37
|
+
function parse_shortcut(shortcut) {
|
|
38
|
+
const parts = shortcut.toLowerCase().split(`+`).map((part) => part.trim());
|
|
39
|
+
const key = parts.pop() ?? ``;
|
|
40
|
+
return {
|
|
41
|
+
key,
|
|
42
|
+
ctrl: parts.includes(`ctrl`),
|
|
43
|
+
shift: parts.includes(`shift`),
|
|
44
|
+
alt: parts.includes(`alt`),
|
|
45
|
+
meta: parts.includes(`meta`) || parts.includes(`cmd`),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function matches_shortcut(event, shortcut) {
|
|
49
|
+
if (!shortcut)
|
|
50
|
+
return false;
|
|
51
|
+
const parsed = parse_shortcut(shortcut);
|
|
52
|
+
// Require non-empty key to prevent "ctrl+" from matching any key with ctrl pressed
|
|
53
|
+
if (!parsed.key)
|
|
54
|
+
return false;
|
|
55
|
+
const key_matches = event.key.toLowerCase() === parsed.key;
|
|
56
|
+
const ctrl_matches = event.ctrlKey === parsed.ctrl;
|
|
57
|
+
const shift_matches = event.shiftKey === parsed.shift;
|
|
58
|
+
const alt_matches = event.altKey === parsed.alt;
|
|
59
|
+
const meta_matches = event.metaKey === parsed.meta;
|
|
60
|
+
return (key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches);
|
|
61
|
+
}
|
|
62
|
+
// Default shortcuts
|
|
63
|
+
const default_shortcuts = {
|
|
64
|
+
select_all: `ctrl+a`,
|
|
65
|
+
clear_all: `ctrl+shift+a`,
|
|
66
|
+
open: null,
|
|
67
|
+
close: null,
|
|
68
|
+
};
|
|
69
|
+
const effective_shortcuts = $derived({
|
|
70
|
+
...default_shortcuts,
|
|
71
|
+
...shortcuts,
|
|
72
|
+
});
|
|
73
|
+
// Extract loadOptions config into single derived object (supports both simple function and config object)
|
|
74
|
+
const load_options_config = $derived.by(() => {
|
|
75
|
+
if (!loadOptions)
|
|
76
|
+
return null;
|
|
77
|
+
const is_fn = typeof loadOptions === `function`;
|
|
78
|
+
return {
|
|
79
|
+
fetch: is_fn ? loadOptions : loadOptions.fetch,
|
|
80
|
+
debounce_ms: is_fn ? 300 : (loadOptions.debounceMs ?? 300),
|
|
81
|
+
batch_size: is_fn ? 50 : (loadOptions.batchSize ?? 50),
|
|
82
|
+
on_open: is_fn ? true : (loadOptions.onOpen ?? true),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
// Helper to compare arrays/values for equality to avoid unnecessary updates.
|
|
41
86
|
// Prevents infinite loops when value/selected are bound to reactive wrappers
|
|
42
87
|
// that clone arrays on assignment (e.g. Superforms, Svelte stores). See issue #309.
|
|
88
|
+
// Treats null/undefined/[] as equivalent empty states to prevent extra updates on init (#369).
|
|
43
89
|
function values_equal(val1, val2) {
|
|
44
90
|
if (val1 === val2)
|
|
45
91
|
return true;
|
|
92
|
+
const empty1 = val1 == null || (Array.isArray(val1) && val1.length === 0);
|
|
93
|
+
const empty2 = val2 == null || (Array.isArray(val2) && val2.length === 0);
|
|
94
|
+
if (empty1 && empty2)
|
|
95
|
+
return true;
|
|
46
96
|
if (Array.isArray(val1) && Array.isArray(val2)) {
|
|
47
97
|
return val1.length === val2.length &&
|
|
48
98
|
val1.every((item, idx) => item === val2[idx]);
|
|
@@ -66,6 +116,15 @@ $effect.pre(() => {
|
|
|
66
116
|
});
|
|
67
117
|
let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
|
|
68
118
|
let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
|
|
119
|
+
// Track last selection action for aria-live announcements
|
|
120
|
+
let last_action = $state(null);
|
|
121
|
+
// Clear last_action after announcement so option counts can be announced again
|
|
122
|
+
$effect(() => {
|
|
123
|
+
if (last_action) {
|
|
124
|
+
const timer = setTimeout(() => (last_action = null), 1000);
|
|
125
|
+
return () => clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
69
128
|
// Internal state for loadOptions feature (null = never loaded)
|
|
70
129
|
let loaded_options = $state([]);
|
|
71
130
|
let load_options_has_more = $state(true);
|
|
@@ -76,6 +135,130 @@ let effective_options = $derived(loadOptions ? loaded_options : (options ?? []))
|
|
|
76
135
|
// Cache selected keys and labels to avoid repeated .map() calls
|
|
77
136
|
let selected_keys = $derived(selected.map(key));
|
|
78
137
|
let selected_labels = $derived(selected.map(get_label));
|
|
138
|
+
// Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
|
|
139
|
+
let selected_keys_set = $derived(new Set(selected_keys));
|
|
140
|
+
let selected_labels_set = $derived(new Set(selected_labels));
|
|
141
|
+
// Memoized Set of disabled option keys for O(1) lookups in large option sets
|
|
142
|
+
let disabled_option_keys = $derived(new Set(effective_options
|
|
143
|
+
.filter((opt) => is_object(opt) && opt.disabled)
|
|
144
|
+
.map(key)));
|
|
145
|
+
// Check if an option is disabled (uses memoized Set for O(1) lookup)
|
|
146
|
+
const is_disabled = (opt) => disabled_option_keys.has(key(opt));
|
|
147
|
+
// Group matching options by their `group` key
|
|
148
|
+
// Note: SvelteMap used here to satisfy eslint svelte/prefer-svelte-reactivity rule,
|
|
149
|
+
// though a plain Map would work since this is recreated fresh on each derivation
|
|
150
|
+
let grouped_options = $derived.by(() => {
|
|
151
|
+
const groups_map = new SvelteMap();
|
|
152
|
+
const ungrouped = [];
|
|
153
|
+
for (const opt of matchingOptions) {
|
|
154
|
+
if (has_group(opt)) {
|
|
155
|
+
const existing = groups_map.get(opt.group);
|
|
156
|
+
if (existing)
|
|
157
|
+
existing.push(opt);
|
|
158
|
+
else
|
|
159
|
+
groups_map.set(opt.group, [opt]);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
ungrouped.push(opt);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let grouped = [...groups_map.entries()].map(([group, options]) => ({
|
|
166
|
+
group,
|
|
167
|
+
options,
|
|
168
|
+
collapsed: collapsedGroups.has(group),
|
|
169
|
+
}));
|
|
170
|
+
// Apply group sorting if specified
|
|
171
|
+
if (groupSortOrder && groupSortOrder !== `none`) {
|
|
172
|
+
grouped = grouped.toSorted((group_a, group_b) => {
|
|
173
|
+
if (typeof groupSortOrder === `function`) {
|
|
174
|
+
return groupSortOrder(group_a.group, group_b.group);
|
|
175
|
+
}
|
|
176
|
+
const cmp = group_a.group.localeCompare(group_b.group);
|
|
177
|
+
return groupSortOrder === `desc` ? -cmp : cmp;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (ungrouped.length === 0)
|
|
181
|
+
return grouped;
|
|
182
|
+
const ungrouped_entry = { group: null, options: ungrouped, collapsed: false };
|
|
183
|
+
return ungroupedPosition === `first`
|
|
184
|
+
? [ungrouped_entry, ...grouped]
|
|
185
|
+
: [...grouped, ungrouped_entry];
|
|
186
|
+
});
|
|
187
|
+
// Flattened options for navigation (excludes options in collapsed groups)
|
|
188
|
+
let navigable_options = $derived(grouped_options.flatMap(({ options: group_opts, collapsed }) => collapsed && collapsibleGroups ? [] : group_opts));
|
|
189
|
+
// Pre-computed Map for O(1) index lookups (avoids O(n²) in template)
|
|
190
|
+
let navigable_index_map = $derived(new Map(navigable_options.map((opt, idx) => [opt, idx])));
|
|
191
|
+
let group_header_state = $derived.by(() => {
|
|
192
|
+
const state = new SvelteMap();
|
|
193
|
+
for (const { group, options: opts, collapsed } of grouped_options) {
|
|
194
|
+
if (group === null)
|
|
195
|
+
continue;
|
|
196
|
+
const selectable = get_selectable_opts(opts, collapsed);
|
|
197
|
+
const all_selected = selectable.length > 0 &&
|
|
198
|
+
selectable.every((opt) => selected_keys_set.has(key(opt)));
|
|
199
|
+
// Count selected options (only needed when keepSelectedInDropdown is enabled)
|
|
200
|
+
let selected_count = 0;
|
|
201
|
+
if (keepSelectedInDropdown) {
|
|
202
|
+
for (const opt of opts) {
|
|
203
|
+
if (selected_keys_set.has(key(opt)))
|
|
204
|
+
selected_count++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
state.set(group, { all_selected, selected_count });
|
|
208
|
+
}
|
|
209
|
+
return state;
|
|
210
|
+
});
|
|
211
|
+
// Update collapsedGroups state: 'add' adds groups, 'delete' removes groups, 'set' replaces all
|
|
212
|
+
function update_collapsed_groups(action, groups) {
|
|
213
|
+
const items = typeof groups === `string` ? [groups] : [...groups];
|
|
214
|
+
if (action === `set`)
|
|
215
|
+
collapsedGroups = new SvelteSet(items);
|
|
216
|
+
else {
|
|
217
|
+
const updated = new SvelteSet(collapsedGroups);
|
|
218
|
+
for (const group of items)
|
|
219
|
+
updated[action](group);
|
|
220
|
+
collapsedGroups = updated;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Toggle group collapsed state
|
|
224
|
+
function toggle_group_collapsed(group_name) {
|
|
225
|
+
const was_collapsed = collapsedGroups.has(group_name);
|
|
226
|
+
update_collapsed_groups(was_collapsed ? `delete` : `add`, group_name);
|
|
227
|
+
ongroupToggle?.({ group: group_name, collapsed: !was_collapsed });
|
|
228
|
+
}
|
|
229
|
+
// Collapse/expand all groups (exposed via bindable props)
|
|
230
|
+
collapseAllGroups = () => {
|
|
231
|
+
const groups = grouped_options
|
|
232
|
+
.map((entry) => entry.group)
|
|
233
|
+
.filter((group_name) => group_name !== null);
|
|
234
|
+
if (groups.length === 0)
|
|
235
|
+
return;
|
|
236
|
+
update_collapsed_groups(`set`, groups);
|
|
237
|
+
oncollapseAll?.({ groups });
|
|
238
|
+
};
|
|
239
|
+
expandAllGroups = () => {
|
|
240
|
+
const groups = [...collapsedGroups];
|
|
241
|
+
if (groups.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
update_collapsed_groups(`set`, []);
|
|
244
|
+
onexpandAll?.({ groups });
|
|
245
|
+
};
|
|
246
|
+
// Expand specified groups and fire ongroupToggle for each
|
|
247
|
+
function expand_groups(groups_to_expand) {
|
|
248
|
+
if (groups_to_expand.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
update_collapsed_groups(`delete`, groups_to_expand);
|
|
251
|
+
for (const group of groups_to_expand)
|
|
252
|
+
ongroupToggle?.({ group, collapsed: false });
|
|
253
|
+
}
|
|
254
|
+
// Get names of collapsed groups that have matching options
|
|
255
|
+
const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
|
|
256
|
+
// Auto-expand collapsed groups when search matches their options
|
|
257
|
+
$effect(() => {
|
|
258
|
+
if (searchExpandsCollapsedGroups && searchText && collapsibleGroups) {
|
|
259
|
+
expand_groups(get_collapsed_with_matches());
|
|
260
|
+
}
|
|
261
|
+
});
|
|
79
262
|
// Normalize placeholder prop (supports string or { text, persistent } object)
|
|
80
263
|
const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
|
|
81
264
|
const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
|
|
@@ -125,30 +308,49 @@ if (maxOptions &&
|
|
|
125
308
|
}
|
|
126
309
|
let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
|
|
127
310
|
let window_width = $state(0);
|
|
128
|
-
//
|
|
311
|
+
// Check if option matches search text (label or optionally group name)
|
|
312
|
+
const matches_search = (opt, search) => {
|
|
313
|
+
if (filterFunc(opt, search))
|
|
314
|
+
return true;
|
|
315
|
+
if (searchMatchesGroups && search && has_group(opt)) {
|
|
316
|
+
return fuzzy
|
|
317
|
+
? fuzzy_match(search, opt.group)
|
|
318
|
+
: opt.group.toLowerCase().includes(search.toLowerCase());
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
};
|
|
129
322
|
$effect.pre(() => {
|
|
130
323
|
// When using loadOptions, server handles filtering, so skip client-side filterFunc
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
324
|
+
matchingOptions = effective_options.filter((opt) => {
|
|
325
|
+
// Check if option is already selected and should be excluded
|
|
326
|
+
const keep_in_list = !selected_keys_set.has(key(opt)) ||
|
|
327
|
+
duplicates ||
|
|
328
|
+
keepSelectedInDropdown;
|
|
329
|
+
if (!keep_in_list)
|
|
330
|
+
return false;
|
|
331
|
+
// When using loadOptions, server handles filtering; otherwise check search match
|
|
332
|
+
return loadOptions || matches_search(opt, searchText);
|
|
333
|
+
});
|
|
136
334
|
});
|
|
137
335
|
// reset activeIndex if out of bounds (can happen when options change while dropdown is open)
|
|
138
336
|
$effect(() => {
|
|
139
|
-
if (activeIndex !== null && !
|
|
337
|
+
if (activeIndex !== null && !navigable_options[activeIndex]) {
|
|
140
338
|
activeIndex = null;
|
|
141
339
|
}
|
|
142
340
|
});
|
|
143
341
|
// update activeOption when activeIndex changes
|
|
144
342
|
$effect(() => {
|
|
145
|
-
activeOption =
|
|
343
|
+
activeOption = navigable_options[activeIndex ?? -1] ?? null;
|
|
146
344
|
});
|
|
345
|
+
// Compute the ID of the currently active option for aria-activedescendant
|
|
346
|
+
const active_option_id = $derived(activeIndex !== null && activeIndex < navigable_options.length
|
|
347
|
+
? `${internal_id}-opt-${activeIndex}`
|
|
348
|
+
: undefined);
|
|
147
349
|
// Helper to check if removing an option would violate minSelect constraint
|
|
148
350
|
const can_remove = $derived(minSelect === null || selected.length > minSelect);
|
|
149
351
|
// toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
|
|
150
352
|
function toggle_option(option_to_toggle, event) {
|
|
151
|
-
const is_currently_selected =
|
|
353
|
+
const is_currently_selected = selected_keys_set.has(key(option_to_toggle));
|
|
152
354
|
if (is_currently_selected) {
|
|
153
355
|
if (can_remove)
|
|
154
356
|
remove(option_to_toggle, event);
|
|
@@ -164,7 +366,7 @@ function add(option_to_add, event) {
|
|
|
164
366
|
if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
|
|
165
367
|
option_to_add = Number(option_to_add); // convert to number if possible
|
|
166
368
|
}
|
|
167
|
-
const is_duplicate =
|
|
369
|
+
const is_duplicate = selected_keys_set.has(key(option_to_add));
|
|
168
370
|
if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
|
|
169
371
|
(duplicates || !is_duplicate)) {
|
|
170
372
|
if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
|
|
@@ -213,6 +415,7 @@ function add(option_to_add, event) {
|
|
|
213
415
|
}
|
|
214
416
|
clear_validity();
|
|
215
417
|
handle_dropdown_after_select(event);
|
|
418
|
+
last_action = { type: `add`, label: `${get_label(option_to_add)}` };
|
|
216
419
|
onadd?.({ option: option_to_add });
|
|
217
420
|
onchange?.({ option: option_to_add, type: `add` });
|
|
218
421
|
}
|
|
@@ -223,7 +426,7 @@ function remove(option_to_drop, event) {
|
|
|
223
426
|
if (selected.length === 0)
|
|
224
427
|
return;
|
|
225
428
|
const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
|
|
226
|
-
let
|
|
429
|
+
let option_removed = selected[idx];
|
|
227
430
|
if (option_removed === undefined && allowUserOptions) {
|
|
228
431
|
// if option with label could not be found but allowUserOptions is truthy,
|
|
229
432
|
// assume it was created by user and create corresponding option object
|
|
@@ -235,8 +438,9 @@ function remove(option_to_drop, event) {
|
|
|
235
438
|
console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
|
|
236
439
|
return;
|
|
237
440
|
}
|
|
238
|
-
selected =
|
|
441
|
+
selected = selected.filter((_, remove_idx) => remove_idx !== idx);
|
|
239
442
|
clear_validity();
|
|
443
|
+
last_action = { type: `remove`, label: `${get_label(option_removed)}` };
|
|
240
444
|
onremove?.({ option: option_removed });
|
|
241
445
|
onchange?.({ option: option_removed, type: `remove` });
|
|
242
446
|
}
|
|
@@ -276,29 +480,36 @@ function handle_dropdown_after_select(event) {
|
|
|
276
480
|
}
|
|
277
481
|
// Check if a user message (create option, duplicate warning, no match) is visible
|
|
278
482
|
const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
|
|
279
|
-
(!duplicates &&
|
|
280
|
-
(
|
|
281
|
-
// Handle arrow key navigation through options (uses
|
|
483
|
+
(!duplicates && selected_labels_set.has(searchText)) ||
|
|
484
|
+
(navigable_options.length === 0 && noMatchingOptionsMsg)));
|
|
485
|
+
// Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
|
|
282
486
|
async function handle_arrow_navigation(direction) {
|
|
283
487
|
ignore_hover = true;
|
|
488
|
+
// Auto-expand collapsed groups when keyboard navigating
|
|
489
|
+
if (keyboardExpandsCollapsedGroups && collapsibleGroups && collapsedGroups.size > 0) {
|
|
490
|
+
expand_groups(get_collapsed_with_matches());
|
|
491
|
+
await tick();
|
|
492
|
+
}
|
|
284
493
|
// toggle user message when no options match but user can create
|
|
285
|
-
if (allowUserOptions && !
|
|
494
|
+
if (allowUserOptions && !navigable_options.length && searchText.length > 0) {
|
|
286
495
|
option_msg_is_active = !option_msg_is_active;
|
|
287
496
|
return;
|
|
288
497
|
}
|
|
289
|
-
if (activeIndex === null && !
|
|
498
|
+
if (activeIndex === null && !navigable_options.length)
|
|
290
499
|
return; // nothing to navigate
|
|
291
500
|
// activate first option or navigate with wrap-around
|
|
292
501
|
if (activeIndex === null) {
|
|
293
502
|
activeIndex = 0;
|
|
294
503
|
}
|
|
295
504
|
else {
|
|
296
|
-
const total =
|
|
505
|
+
const total = navigable_options.length + (has_user_msg ? 1 : 0);
|
|
297
506
|
activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
|
|
298
507
|
}
|
|
299
508
|
// update active state based on new index
|
|
300
|
-
option_msg_is_active = has_user_msg && activeIndex ===
|
|
301
|
-
activeOption = option_msg_is_active
|
|
509
|
+
option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
|
|
510
|
+
activeOption = option_msg_is_active
|
|
511
|
+
? null
|
|
512
|
+
: navigable_options[activeIndex] ?? null;
|
|
302
513
|
if (autoScroll) {
|
|
303
514
|
await tick();
|
|
304
515
|
document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
|
|
@@ -306,6 +517,42 @@ async function handle_arrow_navigation(direction) {
|
|
|
306
517
|
}
|
|
307
518
|
// handle all keyboard events this component receives
|
|
308
519
|
async function handle_keydown(event) {
|
|
520
|
+
if (disabled)
|
|
521
|
+
return; // Block all keyboard handling when disabled
|
|
522
|
+
// Check keyboard shortcuts first (before other key handling)
|
|
523
|
+
const shortcut_actions = [
|
|
524
|
+
{
|
|
525
|
+
key: `select_all`,
|
|
526
|
+
condition: () => !!selectAllOption && navigable_options.length > 0 && maxSelect !== 1,
|
|
527
|
+
action: () => select_all(event),
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
key: `clear_all`,
|
|
531
|
+
condition: () => selected.length > 0,
|
|
532
|
+
action: () => remove_all(event),
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
key: `open`,
|
|
536
|
+
condition: () => !open,
|
|
537
|
+
action: () => open_dropdown(event),
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
key: `close`,
|
|
541
|
+
condition: () => open,
|
|
542
|
+
action: () => {
|
|
543
|
+
close_dropdown(event);
|
|
544
|
+
searchText = ``;
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
for (const { key, condition, action } of shortcut_actions) {
|
|
549
|
+
if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
|
|
550
|
+
event.preventDefault();
|
|
551
|
+
event.stopPropagation();
|
|
552
|
+
action();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
309
556
|
// on escape or tab out of input: close options dropdown and reset search text
|
|
310
557
|
if (event.key === `Escape` || event.key === `Tab`) {
|
|
311
558
|
event.stopPropagation();
|
|
@@ -316,7 +563,7 @@ async function handle_keydown(event) {
|
|
|
316
563
|
event.stopPropagation();
|
|
317
564
|
event.preventDefault(); // prevent enter key from triggering form submission
|
|
318
565
|
if (activeOption) {
|
|
319
|
-
if (
|
|
566
|
+
if (selected_keys_set.has(key(activeOption))) {
|
|
320
567
|
if (can_remove)
|
|
321
568
|
remove(activeOption, event);
|
|
322
569
|
}
|
|
@@ -347,7 +594,7 @@ async function handle_keydown(event) {
|
|
|
347
594
|
}
|
|
348
595
|
// Don't prevent default, allow normal backspace behavior if not removing
|
|
349
596
|
} // make first matching option active on any keypress (if none of the above special cases match)
|
|
350
|
-
else if (
|
|
597
|
+
else if (navigable_options.length > 0 && activeIndex === null) {
|
|
351
598
|
// Don't stop propagation or prevent default here, allow normal character input
|
|
352
599
|
activeIndex = 0;
|
|
353
600
|
}
|
|
@@ -360,45 +607,90 @@ function remove_all(event) {
|
|
|
360
607
|
// If no minSelect constraint, remove all
|
|
361
608
|
removed_options = selected;
|
|
362
609
|
selected = [];
|
|
363
|
-
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
364
610
|
}
|
|
365
611
|
else if (selected.length > minSelect) {
|
|
366
612
|
// Keep the first minSelect items
|
|
367
613
|
removed_options = selected.slice(minSelect);
|
|
368
614
|
selected = selected.slice(0, minSelect);
|
|
369
|
-
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
370
615
|
}
|
|
371
616
|
// Only fire events if something was actually removed
|
|
372
617
|
if (removed_options.length > 0) {
|
|
618
|
+
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
619
|
+
last_action = { type: `removeAll`, label: `${removed_options.length} options` };
|
|
373
620
|
onremoveAll?.({ options: removed_options });
|
|
374
621
|
onchange?.({ options: selected, type: `removeAll` });
|
|
375
622
|
}
|
|
376
623
|
}
|
|
624
|
+
// Check if option index is within maxOptions visibility limit
|
|
625
|
+
const is_option_visible = (idx) => idx >= 0 && (maxOptions == null || idx < maxOptions);
|
|
626
|
+
// Get non-disabled, selectable options from a list
|
|
627
|
+
// For collapsed groups: returns all non-disabled options (user explicitly wants this group)
|
|
628
|
+
// For expanded groups/top-level: respects maxOptions rendering limit
|
|
629
|
+
const get_selectable_opts = (opts, skip_visibility_check = false) => opts.filter((opt) => {
|
|
630
|
+
if (is_disabled(opt))
|
|
631
|
+
return false;
|
|
632
|
+
if (skip_visibility_check)
|
|
633
|
+
return true;
|
|
634
|
+
return is_option_visible(navigable_index_map.get(opt) ?? -1);
|
|
635
|
+
});
|
|
636
|
+
// Batch-add options to selection with all side effects (used by select_all and group select)
|
|
637
|
+
function batch_add_options(options_to_add, event) {
|
|
638
|
+
const remaining = Math.max(0, (maxSelect ?? Infinity) - selected.length);
|
|
639
|
+
const to_add = options_to_add
|
|
640
|
+
.filter((opt) => !selected_keys_set.has(key(opt)))
|
|
641
|
+
.slice(0, remaining);
|
|
642
|
+
if (to_add.length === 0)
|
|
643
|
+
return;
|
|
644
|
+
selected = sort_selected([...selected, ...to_add]);
|
|
645
|
+
if (resetFilterOnAdd)
|
|
646
|
+
searchText = ``;
|
|
647
|
+
clear_validity();
|
|
648
|
+
handle_dropdown_after_select(event);
|
|
649
|
+
onselectAll?.({ options: to_add });
|
|
650
|
+
onchange?.({ options: selected, type: `selectAll` });
|
|
651
|
+
}
|
|
652
|
+
// Batch-add options for top-level "Select all" (only visible/navigable options)
|
|
377
653
|
function select_all(event) {
|
|
378
654
|
event.stopPropagation();
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
655
|
+
batch_add_options(get_selectable_opts(navigable_options), event);
|
|
656
|
+
}
|
|
657
|
+
// Toggle group selection: works even when group is collapsed
|
|
658
|
+
// If all selectable options are selected, deselect them; otherwise select all
|
|
659
|
+
function toggle_group_selection(group_opts, group_collapsed, all_selected, event) {
|
|
660
|
+
event.stopPropagation();
|
|
661
|
+
const selectable = get_selectable_opts(group_opts, group_collapsed);
|
|
662
|
+
if (all_selected) {
|
|
663
|
+
// Deselect all options in this group
|
|
664
|
+
const keys_to_remove = new Set(selectable.map(key));
|
|
665
|
+
const removed = selected.filter((opt) => keys_to_remove.has(key(opt)));
|
|
666
|
+
selected = selected.filter((opt) => !keys_to_remove.has(key(opt)));
|
|
667
|
+
if (removed.length > 0) {
|
|
668
|
+
onremoveAll?.({ options: removed });
|
|
669
|
+
onchange?.({ options: selected, type: `removeAll` });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
// Select all non-disabled, non-selected options in this group
|
|
674
|
+
batch_add_options(selectable, event);
|
|
393
675
|
}
|
|
394
676
|
}
|
|
395
|
-
|
|
677
|
+
// O(1) lookup using pre-computed Set instead of O(n) array.includes()
|
|
678
|
+
const is_selected = (label) => selected_labels_set.has(label);
|
|
396
679
|
const if_enter_or_space = (handler) => (event) => {
|
|
397
680
|
if (event.key === `Enter` || event.code === `Space`) {
|
|
398
681
|
event.preventDefault();
|
|
399
682
|
handler(event);
|
|
400
683
|
}
|
|
401
684
|
};
|
|
685
|
+
// Handle option interaction (click or keyboard) - DRY helper for template
|
|
686
|
+
const handle_option_interact = (opt, opt_disabled, event) => {
|
|
687
|
+
if (opt_disabled)
|
|
688
|
+
return;
|
|
689
|
+
if (keepSelectedInDropdown)
|
|
690
|
+
toggle_option(opt, event);
|
|
691
|
+
else
|
|
692
|
+
add(opt, event);
|
|
693
|
+
};
|
|
402
694
|
function on_click_outside(event) {
|
|
403
695
|
if (!outerDiv)
|
|
404
696
|
return;
|
|
@@ -430,6 +722,8 @@ const drop = (target_idx) => (event) => {
|
|
|
430
722
|
}
|
|
431
723
|
selected = new_selected;
|
|
432
724
|
drag_idx = null;
|
|
725
|
+
onreorder?.({ options: new_selected });
|
|
726
|
+
onchange?.({ options: new_selected, type: `reorder` });
|
|
433
727
|
};
|
|
434
728
|
const dragstart = (idx) => (event) => {
|
|
435
729
|
if (!event.dataTransfer)
|
|
@@ -534,7 +828,8 @@ function portal(node, params) {
|
|
|
534
828
|
}
|
|
535
829
|
// Dynamic options loading - captures search at call time to avoid race conditions
|
|
536
830
|
async function load_dynamic_options(reset) {
|
|
537
|
-
if (!
|
|
831
|
+
if (!load_options_config || load_options_loading ||
|
|
832
|
+
(!reset && !load_options_has_more)) {
|
|
538
833
|
return;
|
|
539
834
|
}
|
|
540
835
|
// Capture search term at call time to avoid race with user typing during fetch
|
|
@@ -542,8 +837,8 @@ async function load_dynamic_options(reset) {
|
|
|
542
837
|
const offset = reset ? 0 : loaded_options.length;
|
|
543
838
|
load_options_loading = true;
|
|
544
839
|
try {
|
|
545
|
-
const limit =
|
|
546
|
-
const result = await
|
|
840
|
+
const limit = load_options_config.batch_size;
|
|
841
|
+
const result = await load_options_config.fetch({ search, offset, limit });
|
|
547
842
|
loaded_options = reset ? result.options : [...loaded_options, ...result.options];
|
|
548
843
|
load_options_has_more = result.hasMore;
|
|
549
844
|
load_options_last_search = search;
|
|
@@ -557,7 +852,7 @@ async function load_dynamic_options(reset) {
|
|
|
557
852
|
}
|
|
558
853
|
// Single effect handles initial load + search changes
|
|
559
854
|
$effect(() => {
|
|
560
|
-
if (!
|
|
855
|
+
if (!load_options_config)
|
|
561
856
|
return;
|
|
562
857
|
// Reset state when dropdown closes so next open triggers fresh load
|
|
563
858
|
if (!open) {
|
|
@@ -571,13 +866,13 @@ $effect(() => {
|
|
|
571
866
|
const search = searchText;
|
|
572
867
|
const is_first_load = load_options_last_search === null;
|
|
573
868
|
if (is_first_load) {
|
|
574
|
-
if (
|
|
869
|
+
if (load_options_config.on_open) {
|
|
575
870
|
// Load immediately on dropdown open
|
|
576
871
|
load_dynamic_options(true);
|
|
577
872
|
}
|
|
578
873
|
else if (search) {
|
|
579
874
|
// onOpen=false but user typed - debounce and load
|
|
580
|
-
debounce_timer = setTimeout(() => load_dynamic_options(true),
|
|
875
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
|
|
581
876
|
}
|
|
582
877
|
// If onOpen=false and no search text, do nothing (wait for user to type)
|
|
583
878
|
}
|
|
@@ -586,7 +881,7 @@ $effect(() => {
|
|
|
586
881
|
// Clear stale results immediately so UI doesn't show wrong results while loading
|
|
587
882
|
loaded_options = [];
|
|
588
883
|
load_options_has_more = true;
|
|
589
|
-
debounce_timer = setTimeout(() => load_dynamic_options(true),
|
|
884
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
|
|
590
885
|
}
|
|
591
886
|
return () => {
|
|
592
887
|
if (debounce_timer)
|
|
@@ -594,7 +889,7 @@ $effect(() => {
|
|
|
594
889
|
};
|
|
595
890
|
});
|
|
596
891
|
function handle_options_scroll(event) {
|
|
597
|
-
if (!
|
|
892
|
+
if (!load_options_config || load_options_loading || !load_options_has_more)
|
|
598
893
|
return;
|
|
599
894
|
const { scrollTop, scrollHeight, clientHeight } = event.target;
|
|
600
895
|
if (scrollHeight - scrollTop - clientHeight <= 100)
|
|
@@ -646,14 +941,16 @@ function handle_options_scroll(event) {
|
|
|
646
941
|
form_input?.setCustomValidity(msg)
|
|
647
942
|
}}
|
|
648
943
|
/>
|
|
649
|
-
|
|
650
|
-
{
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
944
|
+
<span class="expand-icon">
|
|
945
|
+
{#if expandIcon}
|
|
946
|
+
{@render expandIcon({ open })}
|
|
947
|
+
{:else}
|
|
948
|
+
<Icon
|
|
949
|
+
icon="ChevronExpand"
|
|
950
|
+
style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
|
|
951
|
+
/>
|
|
952
|
+
{/if}
|
|
953
|
+
</span>
|
|
657
954
|
<ul
|
|
658
955
|
class="selected {ulSelectedClass}"
|
|
659
956
|
aria-label="selected options"
|
|
@@ -724,6 +1021,12 @@ function handle_options_scroll(event) {
|
|
|
724
1021
|
{inputmode}
|
|
725
1022
|
{pattern}
|
|
726
1023
|
placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
|
|
1024
|
+
role="combobox"
|
|
1025
|
+
aria-haspopup="listbox"
|
|
1026
|
+
aria-expanded={open}
|
|
1027
|
+
aria-controls={listbox_id}
|
|
1028
|
+
aria-activedescendant={active_option_id}
|
|
1029
|
+
aria-busy={loading || load_options_loading || null}
|
|
727
1030
|
aria-invalid={invalid ? `true` : null}
|
|
728
1031
|
ondrop={() => false}
|
|
729
1032
|
onmouseup={open_dropdown}
|
|
@@ -810,6 +1113,7 @@ function handle_options_scroll(event) {
|
|
|
810
1113
|
? NodeFilter.FILTER_REJECT
|
|
811
1114
|
: NodeFilter.FILTER_ACCEPT,
|
|
812
1115
|
})}
|
|
1116
|
+
id={listbox_id}
|
|
813
1117
|
class:hidden={!open}
|
|
814
1118
|
class="options {ulOptionsClass}"
|
|
815
1119
|
role="listbox"
|
|
@@ -835,87 +1139,144 @@ function handle_options_scroll(event) {
|
|
|
835
1139
|
{label}
|
|
836
1140
|
</li>
|
|
837
1141
|
{/if}
|
|
838
|
-
{#each
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
option_item,
|
|
843
|
-
idx
|
|
844
|
-
(duplicates ? `${key(option_item)}-${idx}` : key(option_item))
|
|
1142
|
+
{#each grouped_options as
|
|
1143
|
+
{ group: group_name, options: group_opts, collapsed },
|
|
1144
|
+
group_idx
|
|
1145
|
+
(group_name ?? `ungrouped-${group_idx}`)
|
|
845
1146
|
}
|
|
846
|
-
{
|
|
1147
|
+
{#if group_name !== null}
|
|
1148
|
+
{@const { all_selected, selected_count } = group_header_state.get(group_name) ??
|
|
1149
|
+
{ all_selected: false, selected_count: 0 }}
|
|
1150
|
+
{@const handle_toggle = () =>
|
|
1151
|
+
collapsibleGroups && toggle_group_collapsed(group_name)}
|
|
1152
|
+
{@const handle_group_select = (event: Event) =>
|
|
1153
|
+
toggle_group_selection(group_opts, collapsed, all_selected, event)}
|
|
1154
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1155
|
+
<li
|
|
1156
|
+
class="group-header {liGroupHeaderClass}"
|
|
1157
|
+
class:collapsible={collapsibleGroups}
|
|
1158
|
+
class:sticky={stickyGroupHeaders}
|
|
1159
|
+
role={collapsibleGroups ? `button` : `presentation`}
|
|
1160
|
+
aria-expanded={collapsibleGroups ? !collapsed : undefined}
|
|
1161
|
+
aria-label="Group: {group_name}"
|
|
1162
|
+
style={liGroupHeaderStyle}
|
|
1163
|
+
onclick={handle_toggle}
|
|
1164
|
+
onkeydown={if_enter_or_space(handle_toggle)}
|
|
1165
|
+
tabindex={collapsibleGroups ? 0 : -1}
|
|
1166
|
+
>
|
|
1167
|
+
{#if groupHeader}
|
|
1168
|
+
{@render groupHeader({ group: group_name, options: group_opts, collapsed })}
|
|
1169
|
+
{:else}
|
|
1170
|
+
<span class="group-label">{group_name}</span>
|
|
1171
|
+
<span class="group-count">
|
|
1172
|
+
{#if keepSelectedInDropdown && selected_count > 0}
|
|
1173
|
+
({selected_count}/{group_opts.length})
|
|
1174
|
+
{:else}
|
|
1175
|
+
({group_opts.length})
|
|
1176
|
+
{/if}
|
|
1177
|
+
</span>
|
|
1178
|
+
{#if groupSelectAll && (maxSelect === null || maxSelect > 1)}
|
|
1179
|
+
<button
|
|
1180
|
+
type="button"
|
|
1181
|
+
class="group-select-all"
|
|
1182
|
+
class:deselect={all_selected}
|
|
1183
|
+
onclick={handle_group_select}
|
|
1184
|
+
onkeydown={if_enter_or_space(handle_group_select)}
|
|
1185
|
+
>
|
|
1186
|
+
{all_selected ? `Deselect all` : `Select all`}
|
|
1187
|
+
</button>
|
|
1188
|
+
{/if}
|
|
1189
|
+
{#if collapsibleGroups}
|
|
1190
|
+
<Icon
|
|
1191
|
+
icon={collapsed ? `ChevronRight` : `ChevronDown`}
|
|
1192
|
+
style="width: 12px; margin-left: auto"
|
|
1193
|
+
/>
|
|
1194
|
+
{/if}
|
|
1195
|
+
{/if}
|
|
1196
|
+
</li>
|
|
1197
|
+
{/if}
|
|
1198
|
+
{#if !collapsed || !collapsibleGroups}
|
|
1199
|
+
{#each group_opts as
|
|
1200
|
+
option_item,
|
|
1201
|
+
local_idx
|
|
1202
|
+
(duplicates
|
|
1203
|
+
? `${key(option_item)}-${group_idx}-${local_idx}`
|
|
1204
|
+
: key(option_item))
|
|
1205
|
+
}
|
|
1206
|
+
{@const flat_idx = navigable_index_map.get(option_item) ?? -1}
|
|
1207
|
+
{@const {
|
|
847
1208
|
label,
|
|
848
1209
|
disabled = null,
|
|
849
1210
|
title = null,
|
|
850
1211
|
selectedTitle = null,
|
|
851
1212
|
disabledTitle = defaultDisabledTitle,
|
|
852
1213
|
} = is_object(option_item) ? option_item : { label: option_item }}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1214
|
+
{@const active = activeIndex === flat_idx && flat_idx >= 0}
|
|
1215
|
+
{@const selected = is_selected(label)}
|
|
1216
|
+
{@const optionStyle =
|
|
856
1217
|
[get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
|
|
857
1218
|
` `,
|
|
858
1219
|
) ||
|
|
859
1220
|
null}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
/>
|
|
896
|
-
{/if}
|
|
897
|
-
{#if option}
|
|
898
|
-
{@render option({
|
|
1221
|
+
{#if is_option_visible(flat_idx)}
|
|
1222
|
+
<li
|
|
1223
|
+
id="{internal_id}-opt-{flat_idx}"
|
|
1224
|
+
onclick={(event) => handle_option_interact(option_item, disabled, event)}
|
|
1225
|
+
title={disabled ? disabledTitle : (selected && selectedTitle) || title}
|
|
1226
|
+
class:selected
|
|
1227
|
+
class:active
|
|
1228
|
+
class:disabled
|
|
1229
|
+
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
1230
|
+
onmouseover={() => {
|
|
1231
|
+
if (!disabled && !ignore_hover) activeIndex = flat_idx
|
|
1232
|
+
}}
|
|
1233
|
+
onfocus={() => {
|
|
1234
|
+
if (!disabled) activeIndex = flat_idx
|
|
1235
|
+
}}
|
|
1236
|
+
role="option"
|
|
1237
|
+
aria-selected={selected ? `true` : `false`}
|
|
1238
|
+
aria-posinset={flat_idx + 1}
|
|
1239
|
+
aria-setsize={navigable_options.length}
|
|
1240
|
+
style={optionStyle}
|
|
1241
|
+
onkeydown={if_enter_or_space((event) =>
|
|
1242
|
+
handle_option_interact(option_item, disabled, event)
|
|
1243
|
+
)}
|
|
1244
|
+
>
|
|
1245
|
+
{#if keepSelectedInDropdown === `checkboxes`}
|
|
1246
|
+
<input
|
|
1247
|
+
type="checkbox"
|
|
1248
|
+
class="option-checkbox"
|
|
1249
|
+
checked={selected}
|
|
1250
|
+
aria-label="Toggle {get_label(option_item)}"
|
|
1251
|
+
tabindex="-1"
|
|
1252
|
+
/>
|
|
1253
|
+
{/if}
|
|
1254
|
+
{#if option}
|
|
1255
|
+
{@render option({
|
|
899
1256
|
option: option_item,
|
|
900
|
-
idx,
|
|
1257
|
+
idx: flat_idx,
|
|
901
1258
|
})}
|
|
902
|
-
|
|
903
|
-
|
|
1259
|
+
{:else if children}
|
|
1260
|
+
{@render children({
|
|
904
1261
|
option: option_item,
|
|
905
|
-
idx,
|
|
1262
|
+
idx: flat_idx,
|
|
906
1263
|
})}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1264
|
+
{:else if parseLabelsAsHtml}
|
|
1265
|
+
{@html get_label(option_item)}
|
|
1266
|
+
{:else}
|
|
1267
|
+
{get_label(option_item)}
|
|
1268
|
+
{/if}
|
|
1269
|
+
</li>
|
|
1270
|
+
{/if}
|
|
1271
|
+
{/each}
|
|
1272
|
+
{/if}
|
|
913
1273
|
{/each}
|
|
914
1274
|
{#if searchText}
|
|
915
1275
|
{@const text_input_is_duplicate = selected_labels.includes(searchText)}
|
|
916
1276
|
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
|
|
917
1277
|
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
|
|
918
|
-
{@const no_match =
|
|
1278
|
+
{@const no_match =
|
|
1279
|
+
Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
|
|
919
1280
|
`no-match`}
|
|
920
1281
|
{@const msgType = is_dupe || can_create || no_match}
|
|
921
1282
|
{@const msg = msgType && {
|
|
@@ -923,23 +1284,13 @@ function handle_options_scroll(event) {
|
|
|
923
1284
|
create: createOptionMsg,
|
|
924
1285
|
'no-match': noMatchingOptionsMsg,
|
|
925
1286
|
}[msgType]}
|
|
1287
|
+
{@const can_add_user_option = msgType === `create` && allowUserOptions}
|
|
1288
|
+
{@const handle_create = (event: Event) =>
|
|
1289
|
+
can_add_user_option && add(searchText as Option, event)}
|
|
926
1290
|
{#if msg}
|
|
927
1291
|
<li
|
|
928
|
-
onclick={
|
|
929
|
-
|
|
930
|
-
add(searchText as Option, event)
|
|
931
|
-
}
|
|
932
|
-
}}
|
|
933
|
-
onkeydown={(event) => {
|
|
934
|
-
if (
|
|
935
|
-
msgType === `create` &&
|
|
936
|
-
allowUserOptions &&
|
|
937
|
-
(event.key === `Enter` || event.code === `Space`)
|
|
938
|
-
) {
|
|
939
|
-
event.preventDefault()
|
|
940
|
-
add(searchText as Option, event)
|
|
941
|
-
}
|
|
942
|
-
}}
|
|
1292
|
+
onclick={handle_create}
|
|
1293
|
+
onkeydown={can_add_user_option ? if_enter_or_space(handle_create) : undefined}
|
|
943
1294
|
title={msgType === `create`
|
|
944
1295
|
? createOptionMsg
|
|
945
1296
|
: msgType === `dupe`
|
|
@@ -978,18 +1329,45 @@ function handle_options_scroll(event) {
|
|
|
978
1329
|
{/if}
|
|
979
1330
|
</ul>
|
|
980
1331
|
{/if}
|
|
1332
|
+
<!-- Screen reader announcements for dropdown state, option count, and selection changes -->
|
|
1333
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">
|
|
1334
|
+
{#if last_action}
|
|
1335
|
+
{#if last_action.type === `add`}
|
|
1336
|
+
{last_action.label} selected
|
|
1337
|
+
{:else if last_action.type === `remove`}
|
|
1338
|
+
{last_action.label} removed
|
|
1339
|
+
{:else if last_action.type === `removeAll`}
|
|
1340
|
+
{last_action.label} removed
|
|
1341
|
+
{/if}
|
|
1342
|
+
{:else if open}
|
|
1343
|
+
{matchingOptions.length} option{matchingOptions.length === 1 ? `` : `s`} available
|
|
1344
|
+
{/if}
|
|
1345
|
+
</div>
|
|
981
1346
|
</div>
|
|
982
1347
|
|
|
983
1348
|
<style>
|
|
1349
|
+
/* Screen reader only - visually hidden but accessible to assistive technology */
|
|
1350
|
+
.sr-only {
|
|
1351
|
+
position: absolute;
|
|
1352
|
+
width: 1px;
|
|
1353
|
+
height: 1px;
|
|
1354
|
+
padding: 0;
|
|
1355
|
+
margin: -1px;
|
|
1356
|
+
overflow: hidden;
|
|
1357
|
+
clip: rect(0, 0, 0, 0);
|
|
1358
|
+
white-space: nowrap;
|
|
1359
|
+
border: 0;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
984
1362
|
:is(div.multiselect) {
|
|
985
1363
|
position: relative;
|
|
986
1364
|
align-items: center;
|
|
987
1365
|
display: flex;
|
|
988
1366
|
cursor: text;
|
|
989
1367
|
box-sizing: border-box;
|
|
990
|
-
border: var(--sms-border, 1pt solid lightgray);
|
|
1368
|
+
border: var(--sms-border, 1pt solid light-dark(lightgray, #555));
|
|
991
1369
|
border-radius: var(--sms-border-radius, 3pt);
|
|
992
|
-
background: var(--sms-bg);
|
|
1370
|
+
background: var(--sms-bg, light-dark(white, #1a1a1a));
|
|
993
1371
|
width: var(--sms-width);
|
|
994
1372
|
max-width: var(--sms-max-width);
|
|
995
1373
|
padding: var(--sms-padding, 0 3pt);
|
|
@@ -1007,7 +1385,7 @@ function handle_options_scroll(event) {
|
|
|
1007
1385
|
border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
|
|
1008
1386
|
}
|
|
1009
1387
|
:is(div.multiselect.disabled) {
|
|
1010
|
-
background: var(--sms-disabled-bg, lightgray);
|
|
1388
|
+
background: var(--sms-disabled-bg, light-dark(lightgray, #444));
|
|
1011
1389
|
cursor: not-allowed;
|
|
1012
1390
|
}
|
|
1013
1391
|
|
|
@@ -1026,7 +1404,10 @@ function handle_options_scroll(event) {
|
|
|
1026
1404
|
line-height: normal;
|
|
1027
1405
|
transition: 0.3s;
|
|
1028
1406
|
white-space: nowrap;
|
|
1029
|
-
background: var(
|
|
1407
|
+
background: var(
|
|
1408
|
+
--sms-selected-bg,
|
|
1409
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1410
|
+
);
|
|
1030
1411
|
padding: var(--sms-selected-li-padding, 1pt 5pt);
|
|
1031
1412
|
color: var(--sms-selected-text-color, var(--sms-text-color));
|
|
1032
1413
|
}
|
|
@@ -1034,10 +1415,14 @@ function handle_options_scroll(event) {
|
|
|
1034
1415
|
cursor: grab;
|
|
1035
1416
|
}
|
|
1036
1417
|
:is(div.multiselect > ul.selected > li.active) {
|
|
1037
|
-
background: var(
|
|
1418
|
+
background: var(
|
|
1419
|
+
--sms-li-active-bg,
|
|
1420
|
+
var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
|
|
1421
|
+
);
|
|
1038
1422
|
}
|
|
1039
1423
|
:is(div.multiselect button) {
|
|
1040
1424
|
border-radius: 50%;
|
|
1425
|
+
aspect-ratio: 1; /* ensure circle, not ellipse */
|
|
1041
1426
|
display: flex;
|
|
1042
1427
|
transition: 0.2s;
|
|
1043
1428
|
color: inherit;
|
|
@@ -1052,8 +1437,11 @@ function handle_options_scroll(event) {
|
|
|
1052
1437
|
margin: 0 3pt;
|
|
1053
1438
|
}
|
|
1054
1439
|
:is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
|
|
1055
|
-
color: var(--sms-remove-btn-hover-color, lightskyblue);
|
|
1056
|
-
background: var(
|
|
1440
|
+
color: var(--sms-remove-btn-hover-color, light-dark(#0088cc, lightskyblue));
|
|
1441
|
+
background: var(
|
|
1442
|
+
--sms-remove-btn-hover-bg,
|
|
1443
|
+
light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2))
|
|
1444
|
+
);
|
|
1057
1445
|
}
|
|
1058
1446
|
|
|
1059
1447
|
:is(div.multiselect input) {
|
|
@@ -1110,15 +1498,18 @@ function handle_options_scroll(event) {
|
|
|
1110
1498
|
transition: all
|
|
1111
1499
|
0.2s; /* Consider if this transition is desirable with portal positioning */
|
|
1112
1500
|
box-sizing: border-box;
|
|
1113
|
-
background: var(--sms-options-bg,
|
|
1501
|
+
background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
|
|
1114
1502
|
max-height: var(--sms-options-max-height, 50vh);
|
|
1115
1503
|
overscroll-behavior: var(--sms-options-overscroll, none);
|
|
1116
|
-
box-shadow: var(
|
|
1504
|
+
box-shadow: var(
|
|
1505
|
+
--sms-options-shadow,
|
|
1506
|
+
light-dark(0 0 14pt -8pt black, 0 0 14pt -4pt rgba(0, 0, 0, 0.8))
|
|
1507
|
+
);
|
|
1117
1508
|
border: var(--sms-options-border);
|
|
1118
1509
|
border-width: var(--sms-options-border-width);
|
|
1119
1510
|
border-radius: var(--sms-options-border-radius, 1ex);
|
|
1120
1511
|
padding: var(--sms-options-padding);
|
|
1121
|
-
margin: var(--sms-options-margin,
|
|
1512
|
+
margin: var(--sms-options-margin, 6pt 0 0 0);
|
|
1122
1513
|
}
|
|
1123
1514
|
ul.options.hidden {
|
|
1124
1515
|
visibility: hidden;
|
|
@@ -1138,19 +1529,25 @@ function handle_options_scroll(event) {
|
|
|
1138
1529
|
padding: 3pt 2ex;
|
|
1139
1530
|
}
|
|
1140
1531
|
ul.options > li.selected {
|
|
1141
|
-
background: var(
|
|
1532
|
+
background: var(
|
|
1533
|
+
--sms-li-selected-plain-bg,
|
|
1534
|
+
light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))
|
|
1535
|
+
);
|
|
1142
1536
|
border-left: var(
|
|
1143
1537
|
--sms-li-selected-plain-border,
|
|
1144
1538
|
3px solid var(--sms-active-color, cornflowerblue)
|
|
1145
1539
|
);
|
|
1146
1540
|
}
|
|
1147
1541
|
ul.options > li.active {
|
|
1148
|
-
background: var(
|
|
1542
|
+
background: var(
|
|
1543
|
+
--sms-li-active-bg,
|
|
1544
|
+
var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
|
|
1545
|
+
);
|
|
1149
1546
|
}
|
|
1150
1547
|
ul.options > li.disabled {
|
|
1151
1548
|
cursor: not-allowed;
|
|
1152
|
-
background: var(--sms-li-disabled-bg, #f5f5f6);
|
|
1153
|
-
color: var(--sms-li-disabled-text, #b8b8b8);
|
|
1549
|
+
background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a));
|
|
1550
|
+
color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666));
|
|
1154
1551
|
}
|
|
1155
1552
|
/* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
|
|
1156
1553
|
ul.options > li > input.option-checkbox {
|
|
@@ -1161,7 +1558,10 @@ function handle_options_scroll(event) {
|
|
|
1161
1558
|
}
|
|
1162
1559
|
/* Select all option styling */
|
|
1163
1560
|
ul.options > li.select-all {
|
|
1164
|
-
border-bottom: var(
|
|
1561
|
+
border-bottom: var(
|
|
1562
|
+
--sms-select-all-border-bottom,
|
|
1563
|
+
1px solid light-dark(lightgray, #555)
|
|
1564
|
+
);
|
|
1165
1565
|
font-weight: var(--sms-select-all-font-weight, 500);
|
|
1166
1566
|
color: var(--sms-select-all-color, inherit);
|
|
1167
1567
|
background: var(--sms-select-all-bg, transparent);
|
|
@@ -1170,14 +1570,99 @@ function handle_options_scroll(event) {
|
|
|
1170
1570
|
ul.options > li.select-all:hover {
|
|
1171
1571
|
background: var(
|
|
1172
1572
|
--sms-select-all-hover-bg,
|
|
1173
|
-
var(
|
|
1573
|
+
var(
|
|
1574
|
+
--sms-li-active-bg,
|
|
1575
|
+
var(
|
|
1576
|
+
--sms-active-color,
|
|
1577
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1578
|
+
)
|
|
1579
|
+
)
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
/* Group header styling */
|
|
1583
|
+
ul.options > li.group-header {
|
|
1584
|
+
display: flex;
|
|
1585
|
+
align-items: center;
|
|
1586
|
+
font-weight: var(--sms-group-header-font-weight, 600);
|
|
1587
|
+
font-size: var(--sms-group-header-font-size, 0.85em);
|
|
1588
|
+
color: var(--sms-group-header-color, light-dark(#666, #aaa));
|
|
1589
|
+
background: var(--sms-group-header-bg, transparent);
|
|
1590
|
+
padding: var(--sms-group-header-padding, 6pt 1ex 3pt);
|
|
1591
|
+
cursor: default;
|
|
1592
|
+
border-left: none;
|
|
1593
|
+
text-transform: var(--sms-group-header-text-transform, uppercase);
|
|
1594
|
+
letter-spacing: var(--sms-group-header-letter-spacing, 0.5px);
|
|
1595
|
+
}
|
|
1596
|
+
ul.options > li.group-header:not(:first-child) {
|
|
1597
|
+
margin-top: var(--sms-group-header-margin-top, 4pt);
|
|
1598
|
+
border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
|
|
1599
|
+
}
|
|
1600
|
+
ul.options > li.group-header.collapsible {
|
|
1601
|
+
cursor: pointer;
|
|
1602
|
+
}
|
|
1603
|
+
ul.options > li.group-header.collapsible:hover {
|
|
1604
|
+
background: var(
|
|
1605
|
+
--sms-group-header-hover-bg,
|
|
1606
|
+
light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
ul.options > li.group-header .group-label {
|
|
1610
|
+
flex: 1;
|
|
1611
|
+
}
|
|
1612
|
+
ul.options > li.group-header .group-count {
|
|
1613
|
+
opacity: 0.6;
|
|
1614
|
+
font-size: 0.9em;
|
|
1615
|
+
font-weight: normal;
|
|
1616
|
+
margin-left: 4pt;
|
|
1617
|
+
}
|
|
1618
|
+
/* Sticky group headers when enabled */
|
|
1619
|
+
ul.options > li.group-header.sticky {
|
|
1620
|
+
position: sticky;
|
|
1621
|
+
top: 0;
|
|
1622
|
+
z-index: 1;
|
|
1623
|
+
background: var(
|
|
1624
|
+
--sms-group-header-sticky-bg,
|
|
1625
|
+
var(--sms-options-bg, light-dark(#fafafa, #1a1a1a))
|
|
1174
1626
|
);
|
|
1175
1627
|
}
|
|
1628
|
+
/* Indent grouped options for visual hierarchy */
|
|
1629
|
+
ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more) {
|
|
1630
|
+
padding-left: var(
|
|
1631
|
+
--sms-group-item-padding-left,
|
|
1632
|
+
var(--sms-group-option-indent, 1.5ex)
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
/* Collapse/expand animation for group chevron icon */
|
|
1636
|
+
ul.options > li.group-header :global(svg) {
|
|
1637
|
+
transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out;
|
|
1638
|
+
}
|
|
1639
|
+
ul.options > li.group-header button.group-select-all {
|
|
1640
|
+
font-size: 0.9em;
|
|
1641
|
+
font-weight: normal;
|
|
1642
|
+
text-transform: none;
|
|
1643
|
+
color: var(--sms-active-color, cornflowerblue);
|
|
1644
|
+
background: transparent;
|
|
1645
|
+
border: none;
|
|
1646
|
+
cursor: pointer;
|
|
1647
|
+
padding: 2pt 4pt;
|
|
1648
|
+
margin-left: 8pt;
|
|
1649
|
+
border-radius: 3pt;
|
|
1650
|
+
aspect-ratio: auto; /* override global button aspect-ratio: 1 */
|
|
1651
|
+
}
|
|
1652
|
+
ul.options > li.group-header button.group-select-all:hover {
|
|
1653
|
+
background: var(
|
|
1654
|
+
--sms-group-select-all-hover-bg,
|
|
1655
|
+
light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
ul.options > li.group-header button.group-select-all.deselect {
|
|
1659
|
+
color: var(--sms-group-deselect-color, light-dark(#c44, #f77));
|
|
1660
|
+
}
|
|
1176
1661
|
:is(span.max-select-msg) {
|
|
1177
1662
|
padding: 0 3pt;
|
|
1178
1663
|
}
|
|
1179
1664
|
::highlight(sms-search-matches) {
|
|
1180
|
-
color: mediumaquamarine;
|
|
1665
|
+
color: light-dark(#1a8870, mediumaquamarine);
|
|
1181
1666
|
}
|
|
1182
1667
|
/* Loading more indicator for infinite scrolling */
|
|
1183
1668
|
ul.options > li.loading-more {
|