svelte-multiselect 11.5.0 → 11.5.2
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/CircleSpinner.svelte +29 -0
- package/dist/CircleSpinner.svelte.d.ts +8 -0
- package/dist/CmdPalette.svelte +74 -0
- package/dist/CmdPalette.svelte.d.ts +76 -0
- package/dist/CodeExample.svelte +85 -0
- package/dist/CodeExample.svelte.d.ts +25 -0
- package/dist/CopyButton.svelte +67 -0
- package/dist/CopyButton.svelte.d.ts +25 -0
- package/dist/FileDetails.svelte +65 -0
- package/dist/FileDetails.svelte.d.ts +22 -0
- package/dist/GitHubCorner.svelte +82 -0
- package/dist/GitHubCorner.svelte.d.ts +13 -0
- package/dist/Icon.svelte +23 -0
- package/dist/Icon.svelte.d.ts +8 -0
- package/dist/MultiSelect.svelte +1725 -0
- package/dist/MultiSelect.svelte.d.ts +25 -0
- package/dist/Nav.svelte +627 -0
- package/dist/Nav.svelte.d.ts +43 -0
- package/dist/PrevNext.svelte +105 -0
- package/dist/PrevNext.svelte.d.ts +56 -0
- package/dist/Toggle.svelte +77 -0
- package/dist/Toggle.svelte.d.ts +11 -0
- package/dist/Wiggle.svelte +22 -0
- package/dist/Wiggle.svelte.d.ts +18 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +698 -0
- package/dist/heading-anchors.d.ts +14 -0
- package/dist/heading-anchors.js +120 -0
- package/dist/icons.d.ts +55 -0
- package/dist/icons.js +54 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +43 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +63 -0
- package/package.json +20 -17
- package/readme.md +25 -1
|
@@ -0,0 +1,1725 @@
|
|
|
1
|
+
<!-- eslint-disable-next-line @stylistic/quotes -- TS generics require string literals -->
|
|
2
|
+
<script lang="ts" generics="Option extends import('./types').Option">import { tick, untrack } from 'svelte';
|
|
3
|
+
import { flip } from 'svelte/animate';
|
|
4
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
5
|
+
import { highlight_matches } from './attachments';
|
|
6
|
+
import CircleSpinner from './CircleSpinner.svelte';
|
|
7
|
+
import Icon from './Icon.svelte';
|
|
8
|
+
import { fuzzy_match, get_label, get_style, has_group, is_object } from './utils';
|
|
9
|
+
import Wiggle from './Wiggle.svelte';
|
|
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) => {
|
|
11
|
+
if (!searchText)
|
|
12
|
+
return true;
|
|
13
|
+
const label = `${get_label(opt)}`;
|
|
14
|
+
return fuzzy
|
|
15
|
+
? fuzzy_match(searchText, label)
|
|
16
|
+
: label.toLowerCase().includes(searchText.toLowerCase());
|
|
17
|
+
}, fuzzy = true, closeDropdownOnSelect = false, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = $bindable(null), maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), value = $bindable(null), selected = $bindable(value !== null && value !== undefined
|
|
18
|
+
? (Array.isArray(value) ? value : [value])
|
|
19
|
+
: (options
|
|
20
|
+
?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
|
|
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 = {},
|
|
22
|
+
// Select all feature
|
|
23
|
+
selectAllOption = false, liSelectAllClass = ``,
|
|
24
|
+
// Dynamic options loading
|
|
25
|
+
loadOptions,
|
|
26
|
+
// Animation parameters for selected options flip animation
|
|
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, onsearch, onmaxreached, onduplicate, onactivate, 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.
|
|
86
|
+
// Prevents infinite loops when value/selected are bound to reactive wrappers
|
|
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).
|
|
89
|
+
function values_equal(val1, val2) {
|
|
90
|
+
if (val1 === val2)
|
|
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;
|
|
96
|
+
if (Array.isArray(val1) && Array.isArray(val2)) {
|
|
97
|
+
return val1.length === val2.length &&
|
|
98
|
+
val1.every((item, idx) => item === val2[idx]);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// Sync selected ↔ value bidirectionally. Use untrack to prevent each effect from
|
|
103
|
+
// reacting to changes in the "destination" value, and values_equal to prevent
|
|
104
|
+
// infinite loops with reactive wrappers that clone arrays. See issue #309.
|
|
105
|
+
$effect.pre(() => {
|
|
106
|
+
const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
|
|
107
|
+
if (!values_equal(untrack(() => value), new_value))
|
|
108
|
+
value = new_value;
|
|
109
|
+
});
|
|
110
|
+
$effect.pre(() => {
|
|
111
|
+
const new_selected = maxSelect === 1
|
|
112
|
+
? (value ? [value] : [])
|
|
113
|
+
: (Array.isArray(value) ? value : []);
|
|
114
|
+
if (!values_equal(untrack(() => selected), new_selected))
|
|
115
|
+
selected = new_selected;
|
|
116
|
+
});
|
|
117
|
+
let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
|
|
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
|
+
});
|
|
128
|
+
// Debounced onsearch event - fires 150ms after search text stops changing
|
|
129
|
+
let search_debounce_timer = null;
|
|
130
|
+
let search_initialized = false;
|
|
131
|
+
$effect(() => {
|
|
132
|
+
const current_search = searchText;
|
|
133
|
+
// Skip initial mount - only fire on actual user input
|
|
134
|
+
if (!search_initialized) {
|
|
135
|
+
search_initialized = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!onsearch)
|
|
139
|
+
return; // cleanup handles any pending timer
|
|
140
|
+
search_debounce_timer = setTimeout(() => {
|
|
141
|
+
// Optional chaining in case onsearch is removed while timer is pending
|
|
142
|
+
onsearch?.({
|
|
143
|
+
searchText: current_search,
|
|
144
|
+
matchingCount: matchingOptions.length,
|
|
145
|
+
});
|
|
146
|
+
}, 150);
|
|
147
|
+
return () => {
|
|
148
|
+
if (search_debounce_timer)
|
|
149
|
+
clearTimeout(search_debounce_timer);
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
// Internal state for loadOptions feature (null = never loaded)
|
|
153
|
+
let loaded_options = $state([]);
|
|
154
|
+
let load_options_has_more = $state(true);
|
|
155
|
+
let load_options_loading = $state(false);
|
|
156
|
+
let load_options_last_search = $state(null);
|
|
157
|
+
let debounce_timer = null;
|
|
158
|
+
let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
|
|
159
|
+
// Cache selected keys and labels to avoid repeated .map() calls
|
|
160
|
+
let selected_keys = $derived(selected.map(key));
|
|
161
|
+
let selected_labels = $derived(selected.map(get_label));
|
|
162
|
+
// Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
|
|
163
|
+
let selected_keys_set = $derived(new Set(selected_keys));
|
|
164
|
+
let selected_labels_set = $derived(new Set(selected_labels));
|
|
165
|
+
// Memoized Set of disabled option keys for O(1) lookups in large option sets
|
|
166
|
+
let disabled_option_keys = $derived(new Set(effective_options
|
|
167
|
+
.filter((opt) => is_object(opt) && opt.disabled)
|
|
168
|
+
.map(key)));
|
|
169
|
+
// Check if an option is disabled (uses memoized Set for O(1) lookup)
|
|
170
|
+
const is_disabled = (opt) => disabled_option_keys.has(key(opt));
|
|
171
|
+
// Group matching options by their `group` key
|
|
172
|
+
// Note: SvelteMap used here to satisfy eslint svelte/prefer-svelte-reactivity rule,
|
|
173
|
+
// though a plain Map would work since this is recreated fresh on each derivation
|
|
174
|
+
let grouped_options = $derived.by(() => {
|
|
175
|
+
const groups_map = new SvelteMap();
|
|
176
|
+
const ungrouped = [];
|
|
177
|
+
for (const opt of matchingOptions) {
|
|
178
|
+
if (has_group(opt)) {
|
|
179
|
+
const existing = groups_map.get(opt.group);
|
|
180
|
+
if (existing)
|
|
181
|
+
existing.push(opt);
|
|
182
|
+
else
|
|
183
|
+
groups_map.set(opt.group, [opt]);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
ungrouped.push(opt);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let grouped = [...groups_map.entries()].map(([group, options]) => ({
|
|
190
|
+
group,
|
|
191
|
+
options,
|
|
192
|
+
collapsed: collapsedGroups.has(group),
|
|
193
|
+
}));
|
|
194
|
+
// Apply group sorting if specified
|
|
195
|
+
if (groupSortOrder && groupSortOrder !== `none`) {
|
|
196
|
+
grouped = grouped.toSorted((group_a, group_b) => {
|
|
197
|
+
if (typeof groupSortOrder === `function`) {
|
|
198
|
+
return groupSortOrder(group_a.group, group_b.group);
|
|
199
|
+
}
|
|
200
|
+
const cmp = group_a.group.localeCompare(group_b.group);
|
|
201
|
+
return groupSortOrder === `desc` ? -cmp : cmp;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (ungrouped.length === 0)
|
|
205
|
+
return grouped;
|
|
206
|
+
const ungrouped_entry = { group: null, options: ungrouped, collapsed: false };
|
|
207
|
+
return ungroupedPosition === `first`
|
|
208
|
+
? [ungrouped_entry, ...grouped]
|
|
209
|
+
: [...grouped, ungrouped_entry];
|
|
210
|
+
});
|
|
211
|
+
// Flattened options for navigation (excludes options in collapsed groups)
|
|
212
|
+
let navigable_options = $derived(grouped_options.flatMap(({ options: group_opts, collapsed }) => collapsed && collapsibleGroups ? [] : group_opts));
|
|
213
|
+
// Pre-computed Map for O(1) index lookups (avoids O(n²) in template)
|
|
214
|
+
let navigable_index_map = $derived(new Map(navigable_options.map((opt, idx) => [opt, idx])));
|
|
215
|
+
let group_header_state = $derived.by(() => {
|
|
216
|
+
const state = new SvelteMap();
|
|
217
|
+
for (const { group, options: opts, collapsed } of grouped_options) {
|
|
218
|
+
if (group === null)
|
|
219
|
+
continue;
|
|
220
|
+
const selectable = get_selectable_opts(opts, collapsed);
|
|
221
|
+
const all_selected = selectable.length > 0 &&
|
|
222
|
+
selectable.every((opt) => selected_keys_set.has(key(opt)));
|
|
223
|
+
// Count selected options (only needed when keepSelectedInDropdown is enabled)
|
|
224
|
+
let selected_count = 0;
|
|
225
|
+
if (keepSelectedInDropdown) {
|
|
226
|
+
for (const opt of opts) {
|
|
227
|
+
if (selected_keys_set.has(key(opt)))
|
|
228
|
+
selected_count++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
state.set(group, { all_selected, selected_count });
|
|
232
|
+
}
|
|
233
|
+
return state;
|
|
234
|
+
});
|
|
235
|
+
// Update collapsedGroups state: 'add' adds groups, 'delete' removes groups, 'set' replaces all
|
|
236
|
+
function update_collapsed_groups(action, groups) {
|
|
237
|
+
const items = typeof groups === `string` ? [groups] : [...groups];
|
|
238
|
+
if (action === `set`)
|
|
239
|
+
collapsedGroups = new SvelteSet(items);
|
|
240
|
+
else {
|
|
241
|
+
const updated = new SvelteSet(collapsedGroups);
|
|
242
|
+
for (const group of items)
|
|
243
|
+
updated[action](group);
|
|
244
|
+
collapsedGroups = updated;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Toggle group collapsed state
|
|
248
|
+
function toggle_group_collapsed(group_name) {
|
|
249
|
+
const was_collapsed = collapsedGroups.has(group_name);
|
|
250
|
+
update_collapsed_groups(was_collapsed ? `delete` : `add`, group_name);
|
|
251
|
+
ongroupToggle?.({ group: group_name, collapsed: !was_collapsed });
|
|
252
|
+
}
|
|
253
|
+
// Collapse/expand all groups (exposed via bindable props)
|
|
254
|
+
collapseAllGroups = () => {
|
|
255
|
+
const groups = grouped_options
|
|
256
|
+
.map((entry) => entry.group)
|
|
257
|
+
.filter((group_name) => group_name !== null);
|
|
258
|
+
if (groups.length === 0)
|
|
259
|
+
return;
|
|
260
|
+
update_collapsed_groups(`set`, groups);
|
|
261
|
+
oncollapseAll?.({ groups });
|
|
262
|
+
};
|
|
263
|
+
expandAllGroups = () => {
|
|
264
|
+
const groups = [...collapsedGroups];
|
|
265
|
+
if (groups.length === 0)
|
|
266
|
+
return;
|
|
267
|
+
update_collapsed_groups(`set`, []);
|
|
268
|
+
onexpandAll?.({ groups });
|
|
269
|
+
};
|
|
270
|
+
// Expand specified groups and fire ongroupToggle for each
|
|
271
|
+
function expand_groups(groups_to_expand) {
|
|
272
|
+
if (groups_to_expand.length === 0)
|
|
273
|
+
return;
|
|
274
|
+
update_collapsed_groups(`delete`, groups_to_expand);
|
|
275
|
+
for (const group of groups_to_expand)
|
|
276
|
+
ongroupToggle?.({ group, collapsed: false });
|
|
277
|
+
}
|
|
278
|
+
// Get names of collapsed groups that have matching options
|
|
279
|
+
const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
|
|
280
|
+
// Auto-expand collapsed groups when search matches their options
|
|
281
|
+
$effect(() => {
|
|
282
|
+
if (searchExpandsCollapsedGroups && searchText && collapsibleGroups) {
|
|
283
|
+
expand_groups(get_collapsed_with_matches());
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
// Normalize placeholder prop (supports string or { text, persistent } object)
|
|
287
|
+
const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
|
|
288
|
+
const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
|
|
289
|
+
// Helper to sort selected options (used by add() and select_all())
|
|
290
|
+
function sort_selected(items) {
|
|
291
|
+
if (sortSelected === true) {
|
|
292
|
+
return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
|
|
293
|
+
}
|
|
294
|
+
else if (typeof sortSelected === `function`) {
|
|
295
|
+
return items.toSorted(sortSelected);
|
|
296
|
+
}
|
|
297
|
+
return items;
|
|
298
|
+
}
|
|
299
|
+
untrack(() => {
|
|
300
|
+
if (!loadOptions && !((options?.length ?? 0) > 0)) {
|
|
301
|
+
if (allowUserOptions || loading || disabled || allowEmpty) {
|
|
302
|
+
options = []; // initializing as array avoids errors when component mounts
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// error on empty options if user is not allowed to create custom options and loading is false
|
|
306
|
+
// and component is not disabled and allowEmpty is false
|
|
307
|
+
console.error(`MultiSelect: received no options`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
if (maxSelect !== null && maxSelect < 1) {
|
|
312
|
+
console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
|
|
313
|
+
}
|
|
314
|
+
if (!Array.isArray(selected)) {
|
|
315
|
+
console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
|
|
316
|
+
}
|
|
317
|
+
$effect(() => {
|
|
318
|
+
if (maxSelect && typeof required === `number` && required > maxSelect) {
|
|
319
|
+
console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
|
|
320
|
+
}
|
|
321
|
+
if (parseLabelsAsHtml && allowUserOptions) {
|
|
322
|
+
console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
|
|
323
|
+
}
|
|
324
|
+
if (sortSelected && selectedOptionsDraggable) {
|
|
325
|
+
console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
|
|
326
|
+
`user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
|
|
327
|
+
}
|
|
328
|
+
if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
|
|
329
|
+
console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
|
|
330
|
+
`This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
|
|
331
|
+
}
|
|
332
|
+
if (maxOptions &&
|
|
333
|
+
(typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
|
|
334
|
+
console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
|
|
338
|
+
let window_width = $state(0);
|
|
339
|
+
// Check if option matches search text (label or optionally group name)
|
|
340
|
+
const matches_search = (opt, search) => {
|
|
341
|
+
if (filterFunc(opt, search))
|
|
342
|
+
return true;
|
|
343
|
+
if (searchMatchesGroups && search && has_group(opt)) {
|
|
344
|
+
return fuzzy
|
|
345
|
+
? fuzzy_match(search, opt.group)
|
|
346
|
+
: opt.group.toLowerCase().includes(search.toLowerCase());
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
};
|
|
350
|
+
$effect.pre(() => {
|
|
351
|
+
// When using loadOptions, server handles filtering, so skip client-side filterFunc
|
|
352
|
+
matchingOptions = effective_options.filter((opt) => {
|
|
353
|
+
// Check if option is already selected and should be excluded
|
|
354
|
+
const keep_in_list = !selected_keys_set.has(key(opt)) ||
|
|
355
|
+
duplicates ||
|
|
356
|
+
keepSelectedInDropdown;
|
|
357
|
+
if (!keep_in_list)
|
|
358
|
+
return false;
|
|
359
|
+
// When using loadOptions, server handles filtering; otherwise check search match
|
|
360
|
+
return loadOptions || matches_search(opt, searchText);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
// reset activeIndex if out of bounds (can happen when options change while dropdown is open)
|
|
364
|
+
$effect(() => {
|
|
365
|
+
if (activeIndex !== null && !navigable_options[activeIndex]) {
|
|
366
|
+
activeIndex = null;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// update activeOption when activeIndex changes
|
|
370
|
+
$effect(() => {
|
|
371
|
+
activeOption = navigable_options[activeIndex ?? -1] ?? null;
|
|
372
|
+
});
|
|
373
|
+
// Compute the ID of the currently active option for aria-activedescendant
|
|
374
|
+
const active_option_id = $derived(activeIndex !== null && activeIndex < navigable_options.length
|
|
375
|
+
? `${internal_id}-opt-${activeIndex}`
|
|
376
|
+
: undefined);
|
|
377
|
+
// Helper to check if removing an option would violate minSelect constraint
|
|
378
|
+
const can_remove = $derived(minSelect === null || selected.length > minSelect);
|
|
379
|
+
// toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
|
|
380
|
+
function toggle_option(option_to_toggle, event) {
|
|
381
|
+
const is_currently_selected = selected_keys_set.has(key(option_to_toggle));
|
|
382
|
+
if (is_currently_selected) {
|
|
383
|
+
if (can_remove)
|
|
384
|
+
remove(option_to_toggle, event);
|
|
385
|
+
}
|
|
386
|
+
else
|
|
387
|
+
add(option_to_toggle, event);
|
|
388
|
+
}
|
|
389
|
+
// add an option to selected list
|
|
390
|
+
function add(option_to_add, event) {
|
|
391
|
+
event.stopPropagation();
|
|
392
|
+
if (maxSelect !== null && selected.length >= maxSelect)
|
|
393
|
+
wiggle = true;
|
|
394
|
+
if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
|
|
395
|
+
option_to_add = Number(option_to_add); // convert to number if possible
|
|
396
|
+
}
|
|
397
|
+
const is_duplicate = selected_keys_set.has(key(option_to_add));
|
|
398
|
+
const max_reached = maxSelect !== null && maxSelect !== 1 &&
|
|
399
|
+
selected.length >= maxSelect;
|
|
400
|
+
// Fire events for blocked add attempts
|
|
401
|
+
if (max_reached) {
|
|
402
|
+
onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
|
|
403
|
+
}
|
|
404
|
+
if (is_duplicate && !duplicates)
|
|
405
|
+
onduplicate?.({ option: option_to_add });
|
|
406
|
+
if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
|
|
407
|
+
(duplicates || !is_duplicate)) {
|
|
408
|
+
if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
|
|
409
|
+
// this has the side-effect of not allowing to user to add the same
|
|
410
|
+
// custom option twice in append mode
|
|
411
|
+
[true, `append`].includes(allowUserOptions) &&
|
|
412
|
+
searchText.length > 0) {
|
|
413
|
+
// user entered text but no options match, so if allowUserOptions = true | 'append', we create
|
|
414
|
+
// a new option from the user-entered text
|
|
415
|
+
if (typeof effective_options[0] === `object`) {
|
|
416
|
+
// if 1st option is an object, we create new option as object to keep type homogeneity
|
|
417
|
+
option_to_add = { label: searchText };
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
|
|
421
|
+
!isNaN(Number(searchText))) {
|
|
422
|
+
// create new option as number if it parses to a number and 1st option is also number or missing
|
|
423
|
+
option_to_add = Number(searchText);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
option_to_add = searchText; // else create custom option as string
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Fire oncreate event for all user-created options, regardless of type
|
|
430
|
+
oncreate?.({ option: option_to_add });
|
|
431
|
+
if (allowUserOptions === `append`) {
|
|
432
|
+
if (loadOptions) {
|
|
433
|
+
loaded_options = [...loaded_options, option_to_add];
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
options = [...(options ?? []), option_to_add];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (resetFilterOnAdd)
|
|
441
|
+
searchText = ``; // reset search string on selection
|
|
442
|
+
if ([``, undefined, null].includes(option_to_add)) {
|
|
443
|
+
console.error(`MultiSelect: encountered falsy option`, option_to_add);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// for maxSelect = 1 we always replace current option with new one
|
|
447
|
+
if (maxSelect === 1)
|
|
448
|
+
selected = [option_to_add];
|
|
449
|
+
else {
|
|
450
|
+
selected = sort_selected([...selected, option_to_add]);
|
|
451
|
+
}
|
|
452
|
+
clear_validity();
|
|
453
|
+
handle_dropdown_after_select(event);
|
|
454
|
+
last_action = { type: `add`, label: `${get_label(option_to_add)}` };
|
|
455
|
+
onadd?.({ option: option_to_add });
|
|
456
|
+
onchange?.({ option: option_to_add, type: `add` });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// remove an option from selected list
|
|
460
|
+
function remove(option_to_drop, event) {
|
|
461
|
+
event.stopPropagation();
|
|
462
|
+
if (selected.length === 0)
|
|
463
|
+
return;
|
|
464
|
+
const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
|
|
465
|
+
let option_removed = selected[idx];
|
|
466
|
+
if (option_removed === undefined && allowUserOptions) {
|
|
467
|
+
// if option with label could not be found but allowUserOptions is truthy,
|
|
468
|
+
// assume it was created by user and create corresponding option object
|
|
469
|
+
// on the fly for use as event payload
|
|
470
|
+
const is_object_option = typeof effective_options[0] === `object`;
|
|
471
|
+
option_removed = (is_object_option ? { label: option_to_drop } : option_to_drop);
|
|
472
|
+
}
|
|
473
|
+
if (option_removed === undefined) {
|
|
474
|
+
console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
selected = selected.filter((_, remove_idx) => remove_idx !== idx);
|
|
478
|
+
clear_validity();
|
|
479
|
+
last_action = { type: `remove`, label: `${get_label(option_removed)}` };
|
|
480
|
+
onremove?.({ option: option_removed });
|
|
481
|
+
onchange?.({ option: option_removed, type: `remove` });
|
|
482
|
+
}
|
|
483
|
+
function open_dropdown(event) {
|
|
484
|
+
event.stopPropagation();
|
|
485
|
+
if (disabled)
|
|
486
|
+
return;
|
|
487
|
+
open = true;
|
|
488
|
+
if (!(event instanceof FocusEvent)) {
|
|
489
|
+
// avoid double-focussing input when event that opened dropdown was already input FocusEvent
|
|
490
|
+
input?.focus();
|
|
491
|
+
}
|
|
492
|
+
onopen?.({ event });
|
|
493
|
+
}
|
|
494
|
+
function close_dropdown(event, retain_focus = false) {
|
|
495
|
+
open = false;
|
|
496
|
+
if (!retain_focus)
|
|
497
|
+
input?.blur();
|
|
498
|
+
activeIndex = null;
|
|
499
|
+
onclose?.({ event });
|
|
500
|
+
}
|
|
501
|
+
function clear_validity() {
|
|
502
|
+
invalid = false;
|
|
503
|
+
form_input?.setCustomValidity(``);
|
|
504
|
+
}
|
|
505
|
+
function handle_dropdown_after_select(event) {
|
|
506
|
+
const reached_max = selected.length >= (maxSelect ?? Infinity);
|
|
507
|
+
const should_close = closeDropdownOnSelect === true ||
|
|
508
|
+
closeDropdownOnSelect === `retain-focus` ||
|
|
509
|
+
(closeDropdownOnSelect === `if-mobile` && window_width &&
|
|
510
|
+
window_width < breakpoint);
|
|
511
|
+
if (reached_max || should_close) {
|
|
512
|
+
close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
|
|
513
|
+
}
|
|
514
|
+
else
|
|
515
|
+
input?.focus();
|
|
516
|
+
}
|
|
517
|
+
// Check if a user message (create option, duplicate warning, no match) is visible
|
|
518
|
+
const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
|
|
519
|
+
(!duplicates && selected_labels_set.has(searchText)) ||
|
|
520
|
+
(navigable_options.length === 0 && noMatchingOptionsMsg)));
|
|
521
|
+
// Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
|
|
522
|
+
async function handle_arrow_navigation(direction) {
|
|
523
|
+
ignore_hover = true;
|
|
524
|
+
// Auto-expand collapsed groups when keyboard navigating
|
|
525
|
+
if (keyboardExpandsCollapsedGroups && collapsibleGroups && collapsedGroups.size > 0) {
|
|
526
|
+
expand_groups(get_collapsed_with_matches());
|
|
527
|
+
await tick();
|
|
528
|
+
}
|
|
529
|
+
// toggle user message when no options match but user can create
|
|
530
|
+
if (allowUserOptions && !navigable_options.length && searchText.length > 0) {
|
|
531
|
+
option_msg_is_active = !option_msg_is_active;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (activeIndex === null && !navigable_options.length)
|
|
535
|
+
return; // nothing to navigate
|
|
536
|
+
// activate first option or navigate with wrap-around
|
|
537
|
+
if (activeIndex === null) {
|
|
538
|
+
activeIndex = 0;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
const total = navigable_options.length + (has_user_msg ? 1 : 0);
|
|
542
|
+
// Guard against division by zero (can happen if options filtered away before effect resets activeIndex)
|
|
543
|
+
if (total === 0) {
|
|
544
|
+
activeIndex = null;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
|
|
548
|
+
}
|
|
549
|
+
// update active state based on new index
|
|
550
|
+
option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
|
|
551
|
+
activeOption = option_msg_is_active
|
|
552
|
+
? null
|
|
553
|
+
: navigable_options[activeIndex] ?? null;
|
|
554
|
+
if (autoScroll) {
|
|
555
|
+
await tick();
|
|
556
|
+
document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
|
|
557
|
+
}
|
|
558
|
+
// Fire onactivate for keyboard navigation only (not mouse hover)
|
|
559
|
+
onactivate?.({ option: activeOption, index: activeIndex });
|
|
560
|
+
}
|
|
561
|
+
// handle all keyboard events this component receives
|
|
562
|
+
async function handle_keydown(event) {
|
|
563
|
+
if (disabled)
|
|
564
|
+
return; // Block all keyboard handling when disabled
|
|
565
|
+
// Check keyboard shortcuts first (before other key handling)
|
|
566
|
+
const shortcut_actions = [
|
|
567
|
+
{
|
|
568
|
+
key: `select_all`,
|
|
569
|
+
condition: () => !!selectAllOption && navigable_options.length > 0 && maxSelect !== 1,
|
|
570
|
+
action: () => select_all(event),
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
key: `clear_all`,
|
|
574
|
+
condition: () => selected.length > 0,
|
|
575
|
+
action: () => remove_all(event),
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
key: `open`,
|
|
579
|
+
condition: () => !open,
|
|
580
|
+
action: () => open_dropdown(event),
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
key: `close`,
|
|
584
|
+
condition: () => open,
|
|
585
|
+
action: () => {
|
|
586
|
+
close_dropdown(event);
|
|
587
|
+
searchText = ``;
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
];
|
|
591
|
+
for (const { key, condition, action } of shortcut_actions) {
|
|
592
|
+
if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
event.stopPropagation();
|
|
595
|
+
action();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// on escape or tab out of input: close options dropdown and reset search text
|
|
600
|
+
if (event.key === `Escape` || event.key === `Tab`) {
|
|
601
|
+
event.stopPropagation();
|
|
602
|
+
close_dropdown(event);
|
|
603
|
+
searchText = ``;
|
|
604
|
+
} // on enter key: toggle active option
|
|
605
|
+
else if (event.key === `Enter`) {
|
|
606
|
+
event.stopPropagation();
|
|
607
|
+
event.preventDefault(); // prevent enter key from triggering form submission
|
|
608
|
+
if (activeOption) {
|
|
609
|
+
if (selected_keys_set.has(key(activeOption))) {
|
|
610
|
+
if (can_remove)
|
|
611
|
+
remove(activeOption, event);
|
|
612
|
+
}
|
|
613
|
+
else
|
|
614
|
+
add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
|
|
615
|
+
}
|
|
616
|
+
else if (allowUserOptions && searchText.length > 0) {
|
|
617
|
+
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
|
|
618
|
+
add(searchText, event);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
// no active option and no search text means the options dropdown is closed
|
|
622
|
+
// in which case enter means open it
|
|
623
|
+
open_dropdown(event);
|
|
624
|
+
}
|
|
625
|
+
} // on up/down arrow keys: update active option
|
|
626
|
+
else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
|
|
627
|
+
event.stopPropagation();
|
|
628
|
+
event.preventDefault();
|
|
629
|
+
await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
|
|
630
|
+
} // on backspace key: remove last selected option
|
|
631
|
+
else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
|
|
632
|
+
event.stopPropagation();
|
|
633
|
+
if (can_remove) {
|
|
634
|
+
const last_option = selected.at(-1);
|
|
635
|
+
if (last_option)
|
|
636
|
+
remove(last_option, event);
|
|
637
|
+
}
|
|
638
|
+
// Don't prevent default, allow normal backspace behavior if not removing
|
|
639
|
+
} // make first matching option active on any keypress (if none of the above special cases match)
|
|
640
|
+
else if (navigable_options.length > 0 && activeIndex === null) {
|
|
641
|
+
// Don't stop propagation or prevent default here, allow normal character input
|
|
642
|
+
activeIndex = 0;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function remove_all(event) {
|
|
646
|
+
event.stopPropagation();
|
|
647
|
+
// Keep the first minSelect items, remove the rest
|
|
648
|
+
let removed_options = [];
|
|
649
|
+
if (minSelect === null) {
|
|
650
|
+
// If no minSelect constraint, remove all
|
|
651
|
+
removed_options = selected;
|
|
652
|
+
selected = [];
|
|
653
|
+
}
|
|
654
|
+
else if (selected.length > minSelect) {
|
|
655
|
+
// Keep the first minSelect items
|
|
656
|
+
removed_options = selected.slice(minSelect);
|
|
657
|
+
selected = selected.slice(0, minSelect);
|
|
658
|
+
}
|
|
659
|
+
// Only fire events if something was actually removed
|
|
660
|
+
if (removed_options.length > 0) {
|
|
661
|
+
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
662
|
+
last_action = { type: `removeAll`, label: `${removed_options.length} options` };
|
|
663
|
+
onremoveAll?.({ options: removed_options });
|
|
664
|
+
onchange?.({ options: selected, type: `removeAll` });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Check if option index is within maxOptions visibility limit
|
|
668
|
+
const is_option_visible = (idx) => idx >= 0 && (maxOptions == null || idx < maxOptions);
|
|
669
|
+
// Get non-disabled, selectable options from a list
|
|
670
|
+
// For collapsed groups: returns all non-disabled options (user explicitly wants this group)
|
|
671
|
+
// For expanded groups/top-level: respects maxOptions rendering limit
|
|
672
|
+
const get_selectable_opts = (opts, skip_visibility_check = false) => opts.filter((opt) => {
|
|
673
|
+
if (is_disabled(opt))
|
|
674
|
+
return false;
|
|
675
|
+
if (skip_visibility_check)
|
|
676
|
+
return true;
|
|
677
|
+
return is_option_visible(navigable_index_map.get(opt) ?? -1);
|
|
678
|
+
});
|
|
679
|
+
// Batch-add options to selection with all side effects (used by select_all and group select)
|
|
680
|
+
function batch_add_options(options_to_add, event) {
|
|
681
|
+
const remaining = Math.max(0, (maxSelect ?? Infinity) - selected.length);
|
|
682
|
+
const to_add = options_to_add
|
|
683
|
+
.filter((opt) => !selected_keys_set.has(key(opt)))
|
|
684
|
+
.slice(0, remaining);
|
|
685
|
+
if (to_add.length === 0)
|
|
686
|
+
return;
|
|
687
|
+
selected = sort_selected([...selected, ...to_add]);
|
|
688
|
+
if (resetFilterOnAdd)
|
|
689
|
+
searchText = ``;
|
|
690
|
+
clear_validity();
|
|
691
|
+
handle_dropdown_after_select(event);
|
|
692
|
+
onselectAll?.({ options: to_add });
|
|
693
|
+
onchange?.({ options: selected, type: `selectAll` });
|
|
694
|
+
}
|
|
695
|
+
// Batch-add options for top-level "Select all" (only visible/navigable options)
|
|
696
|
+
function select_all(event) {
|
|
697
|
+
event.stopPropagation();
|
|
698
|
+
batch_add_options(get_selectable_opts(navigable_options), event);
|
|
699
|
+
}
|
|
700
|
+
// Toggle group selection: works even when group is collapsed
|
|
701
|
+
// If all selectable options are selected, deselect them; otherwise select all
|
|
702
|
+
function toggle_group_selection(group_opts, group_collapsed, all_selected, event) {
|
|
703
|
+
event.stopPropagation();
|
|
704
|
+
const selectable = get_selectable_opts(group_opts, group_collapsed);
|
|
705
|
+
if (all_selected) {
|
|
706
|
+
// Deselect all options in this group
|
|
707
|
+
const keys_to_remove = new Set(selectable.map(key));
|
|
708
|
+
const removed = selected.filter((opt) => keys_to_remove.has(key(opt)));
|
|
709
|
+
selected = selected.filter((opt) => !keys_to_remove.has(key(opt)));
|
|
710
|
+
if (removed.length > 0) {
|
|
711
|
+
onremoveAll?.({ options: removed });
|
|
712
|
+
onchange?.({ options: selected, type: `removeAll` });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
// Select all non-disabled, non-selected options in this group
|
|
717
|
+
batch_add_options(selectable, event);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// O(1) lookup using pre-computed Set instead of O(n) array.includes()
|
|
721
|
+
const is_selected = (label) => selected_labels_set.has(label);
|
|
722
|
+
const if_enter_or_space = (handler) => (event) => {
|
|
723
|
+
if (event.key === `Enter` || event.code === `Space`) {
|
|
724
|
+
event.preventDefault();
|
|
725
|
+
handler(event);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
// Handle option interaction (click or keyboard) - DRY helper for template
|
|
729
|
+
const handle_option_interact = (opt, opt_disabled, event) => {
|
|
730
|
+
if (opt_disabled)
|
|
731
|
+
return;
|
|
732
|
+
if (keepSelectedInDropdown)
|
|
733
|
+
toggle_option(opt, event);
|
|
734
|
+
else
|
|
735
|
+
add(opt, event);
|
|
736
|
+
};
|
|
737
|
+
function on_click_outside(event) {
|
|
738
|
+
if (!outerDiv)
|
|
739
|
+
return;
|
|
740
|
+
const target = event.target;
|
|
741
|
+
// Check if click is inside the main component
|
|
742
|
+
if (outerDiv.contains(target))
|
|
743
|
+
return;
|
|
744
|
+
// If portal is active, also check if click is inside the portalled options dropdown
|
|
745
|
+
if (portal_params?.active && ul_options && ul_options.contains(target))
|
|
746
|
+
return;
|
|
747
|
+
// Click is outside both the main component and any portalled dropdown
|
|
748
|
+
close_dropdown(event);
|
|
749
|
+
}
|
|
750
|
+
let drag_idx = $state(null);
|
|
751
|
+
// event handlers enable dragging to reorder selected options
|
|
752
|
+
const drop = (target_idx) => (event) => {
|
|
753
|
+
if (!event.dataTransfer)
|
|
754
|
+
return;
|
|
755
|
+
event.dataTransfer.dropEffect = `move`;
|
|
756
|
+
const start_idx = parseInt(event.dataTransfer.getData(`text/plain`));
|
|
757
|
+
const new_selected = [...selected];
|
|
758
|
+
if (start_idx < target_idx) {
|
|
759
|
+
new_selected.splice(target_idx + 1, 0, new_selected[start_idx]);
|
|
760
|
+
new_selected.splice(start_idx, 1);
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
new_selected.splice(target_idx, 0, new_selected[start_idx]);
|
|
764
|
+
new_selected.splice(start_idx + 1, 1);
|
|
765
|
+
}
|
|
766
|
+
selected = new_selected;
|
|
767
|
+
drag_idx = null;
|
|
768
|
+
onreorder?.({ options: new_selected });
|
|
769
|
+
onchange?.({ options: new_selected, type: `reorder` });
|
|
770
|
+
};
|
|
771
|
+
const dragstart = (idx) => (event) => {
|
|
772
|
+
if (!event.dataTransfer)
|
|
773
|
+
return;
|
|
774
|
+
// only allow moving, not copying (also affects the cursor during drag)
|
|
775
|
+
event.dataTransfer.effectAllowed = `move`;
|
|
776
|
+
event.dataTransfer.dropEffect = `move`;
|
|
777
|
+
event.dataTransfer.setData(`text/plain`, `${idx}`);
|
|
778
|
+
};
|
|
779
|
+
let ul_options = $state();
|
|
780
|
+
const handle_input_keydown = (event) => {
|
|
781
|
+
handle_keydown(event); // Restore internal logic
|
|
782
|
+
// Call original forwarded handler
|
|
783
|
+
onkeydown?.(event);
|
|
784
|
+
};
|
|
785
|
+
const handle_input_focus = (event) => {
|
|
786
|
+
open_dropdown(event);
|
|
787
|
+
onfocus?.(event);
|
|
788
|
+
};
|
|
789
|
+
// Override input's focus method to ensure dropdown opens on programmatic focus
|
|
790
|
+
// https://github.com/janosh/svelte-multiselect/issues/289
|
|
791
|
+
$effect(() => {
|
|
792
|
+
if (!input)
|
|
793
|
+
return;
|
|
794
|
+
const orig_focus = input.focus.bind(input);
|
|
795
|
+
input.focus = (options) => {
|
|
796
|
+
orig_focus(options);
|
|
797
|
+
if (!disabled && !open) {
|
|
798
|
+
open_dropdown(new FocusEvent(`focus`, { bubbles: true }));
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
return () => {
|
|
802
|
+
if (input)
|
|
803
|
+
input.focus = orig_focus;
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
const handle_input_blur = (event) => {
|
|
807
|
+
// For portalled dropdowns, don't close on blur since clicks on portalled elements
|
|
808
|
+
// will cause blur but we want to allow the click to register first
|
|
809
|
+
// (otherwise mobile touch event is unable to select options https://github.com/janosh/svelte-multiselect/issues/335)
|
|
810
|
+
if (portal_params?.active) {
|
|
811
|
+
onblur?.(event); // Let the click handler manage closing for portalled dropdowns
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// For non-portalled dropdowns, close when focus moves outside the component
|
|
815
|
+
if (!outerDiv?.contains(event.relatedTarget))
|
|
816
|
+
close_dropdown(event);
|
|
817
|
+
onblur?.(event); // Call original handler (if any passed as component prop)
|
|
818
|
+
};
|
|
819
|
+
// reset form validation when required prop changes
|
|
820
|
+
// https://github.com/janosh/svelte-multiselect/issues/285
|
|
821
|
+
$effect.pre(() => {
|
|
822
|
+
required = required; // trigger effect when required changes
|
|
823
|
+
form_input?.setCustomValidity(``);
|
|
824
|
+
});
|
|
825
|
+
function portal(node, params) {
|
|
826
|
+
let { target_node, active } = params;
|
|
827
|
+
if (!active)
|
|
828
|
+
return;
|
|
829
|
+
let render_in_place = typeof window === `undefined` ||
|
|
830
|
+
!document.body.contains(node);
|
|
831
|
+
if (!render_in_place) {
|
|
832
|
+
document.body.appendChild(node);
|
|
833
|
+
node.style.position = `fixed`;
|
|
834
|
+
const update_position = () => {
|
|
835
|
+
if (!target_node || !open)
|
|
836
|
+
return (node.hidden = true);
|
|
837
|
+
const rect = target_node.getBoundingClientRect();
|
|
838
|
+
node.style.left = `${rect.left}px`;
|
|
839
|
+
node.style.top = `${rect.bottom}px`;
|
|
840
|
+
node.style.width = `${rect.width}px`;
|
|
841
|
+
node.hidden = false;
|
|
842
|
+
};
|
|
843
|
+
if (open)
|
|
844
|
+
tick().then(update_position);
|
|
845
|
+
window.addEventListener(`scroll`, update_position, true);
|
|
846
|
+
window.addEventListener(`resize`, update_position);
|
|
847
|
+
$effect(() => {
|
|
848
|
+
if (open && target_node)
|
|
849
|
+
update_position();
|
|
850
|
+
else
|
|
851
|
+
node.hidden = true;
|
|
852
|
+
});
|
|
853
|
+
return {
|
|
854
|
+
update(params) {
|
|
855
|
+
target_node = params.target_node;
|
|
856
|
+
render_in_place = typeof window === `undefined` ||
|
|
857
|
+
!document.body.contains(node);
|
|
858
|
+
if (open && !render_in_place && target_node)
|
|
859
|
+
tick().then(update_position);
|
|
860
|
+
else if (!open || !target_node)
|
|
861
|
+
node.hidden = true;
|
|
862
|
+
},
|
|
863
|
+
destroy() {
|
|
864
|
+
if (!render_in_place)
|
|
865
|
+
node.remove();
|
|
866
|
+
window.removeEventListener(`scroll`, update_position, true);
|
|
867
|
+
window.removeEventListener(`resize`, update_position);
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// Dynamic options loading - captures search at call time to avoid race conditions
|
|
873
|
+
async function load_dynamic_options(reset) {
|
|
874
|
+
if (!load_options_config || load_options_loading ||
|
|
875
|
+
(!reset && !load_options_has_more)) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// Capture search term at call time to avoid race with user typing during fetch
|
|
879
|
+
const search = searchText;
|
|
880
|
+
const offset = reset ? 0 : loaded_options.length;
|
|
881
|
+
load_options_loading = true;
|
|
882
|
+
try {
|
|
883
|
+
const limit = load_options_config.batch_size;
|
|
884
|
+
const result = await load_options_config.fetch({ search, offset, limit });
|
|
885
|
+
loaded_options = reset ? result.options : [...loaded_options, ...result.options];
|
|
886
|
+
load_options_has_more = result.hasMore;
|
|
887
|
+
load_options_last_search = search;
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
console.error(`MultiSelect: loadOptions error:`, err);
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
load_options_loading = false;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Single effect handles initial load + search changes
|
|
897
|
+
$effect(() => {
|
|
898
|
+
if (!load_options_config)
|
|
899
|
+
return;
|
|
900
|
+
// Reset state when dropdown closes so next open triggers fresh load
|
|
901
|
+
if (!open) {
|
|
902
|
+
load_options_last_search = null;
|
|
903
|
+
loaded_options = [];
|
|
904
|
+
load_options_has_more = true;
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (debounce_timer)
|
|
908
|
+
clearTimeout(debounce_timer);
|
|
909
|
+
const search = searchText;
|
|
910
|
+
const is_first_load = load_options_last_search === null;
|
|
911
|
+
if (is_first_load) {
|
|
912
|
+
if (load_options_config.on_open) {
|
|
913
|
+
// Load immediately on dropdown open
|
|
914
|
+
load_dynamic_options(true);
|
|
915
|
+
}
|
|
916
|
+
else if (search) {
|
|
917
|
+
// onOpen=false but user typed - debounce and load
|
|
918
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
|
|
919
|
+
}
|
|
920
|
+
// If onOpen=false and no search text, do nothing (wait for user to type)
|
|
921
|
+
}
|
|
922
|
+
else if (search !== load_options_last_search) {
|
|
923
|
+
// Subsequent loads: debounce search changes
|
|
924
|
+
// Clear stale results immediately so UI doesn't show wrong results while loading
|
|
925
|
+
loaded_options = [];
|
|
926
|
+
load_options_has_more = true;
|
|
927
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_config.debounce_ms);
|
|
928
|
+
}
|
|
929
|
+
return () => {
|
|
930
|
+
if (debounce_timer)
|
|
931
|
+
clearTimeout(debounce_timer);
|
|
932
|
+
};
|
|
933
|
+
});
|
|
934
|
+
function handle_options_scroll(event) {
|
|
935
|
+
if (!load_options_config || load_options_loading || !load_options_has_more)
|
|
936
|
+
return;
|
|
937
|
+
const { scrollTop, scrollHeight, clientHeight } = event.target;
|
|
938
|
+
if (scrollHeight - scrollTop - clientHeight <= 100)
|
|
939
|
+
load_dynamic_options(false);
|
|
940
|
+
}
|
|
941
|
+
</script>
|
|
942
|
+
|
|
943
|
+
<svelte:window
|
|
944
|
+
onclick={on_click_outside}
|
|
945
|
+
ontouchstart={on_click_outside}
|
|
946
|
+
bind:innerWidth={window_width}
|
|
947
|
+
/>
|
|
948
|
+
|
|
949
|
+
<div
|
|
950
|
+
bind:this={outerDiv}
|
|
951
|
+
class:disabled
|
|
952
|
+
class:single={maxSelect === 1}
|
|
953
|
+
class:open
|
|
954
|
+
class:invalid
|
|
955
|
+
class="multiselect {outerDivClass} {rest.class ?? ``}"
|
|
956
|
+
onmouseup={open_dropdown}
|
|
957
|
+
title={disabled ? disabledInputTitle : null}
|
|
958
|
+
data-id={id}
|
|
959
|
+
role="searchbox"
|
|
960
|
+
tabindex="-1"
|
|
961
|
+
{style}
|
|
962
|
+
>
|
|
963
|
+
<!-- form control input invisible to the user, only purpose is to abort form submission if this component fails data validation -->
|
|
964
|
+
<!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
|
|
965
|
+
<input
|
|
966
|
+
{name}
|
|
967
|
+
required={Boolean(required)}
|
|
968
|
+
value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
|
|
969
|
+
tabindex="-1"
|
|
970
|
+
aria-hidden="true"
|
|
971
|
+
aria-label="ignore this, used only to prevent form submission if select is required but empty"
|
|
972
|
+
class="form-control"
|
|
973
|
+
bind:this={form_input}
|
|
974
|
+
oninvalid={() => {
|
|
975
|
+
invalid = true
|
|
976
|
+
let msg
|
|
977
|
+
if (maxSelect && maxSelect > 1 && Number(required) > 1) {
|
|
978
|
+
msg = `Please select between ${required} and ${maxSelect} options`
|
|
979
|
+
} else if (Number(required) > 1) {
|
|
980
|
+
msg = `Please select at least ${required} options`
|
|
981
|
+
} else {
|
|
982
|
+
msg = `Please select an option`
|
|
983
|
+
}
|
|
984
|
+
form_input?.setCustomValidity(msg)
|
|
985
|
+
}}
|
|
986
|
+
/>
|
|
987
|
+
<span class="expand-icon">
|
|
988
|
+
{#if expandIcon}
|
|
989
|
+
{@render expandIcon({ open })}
|
|
990
|
+
{:else}
|
|
991
|
+
<Icon
|
|
992
|
+
icon="ChevronExpand"
|
|
993
|
+
style="width: 15px; min-width: 1em; padding: 0 1pt; cursor: pointer"
|
|
994
|
+
/>
|
|
995
|
+
{/if}
|
|
996
|
+
</span>
|
|
997
|
+
<ul
|
|
998
|
+
class="selected {ulSelectedClass}"
|
|
999
|
+
aria-label="selected options"
|
|
1000
|
+
style={ulSelectedStyle}
|
|
1001
|
+
>
|
|
1002
|
+
{#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
|
|
1003
|
+
{@const selectedOptionStyle =
|
|
1004
|
+
[get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
|
|
1005
|
+
` `,
|
|
1006
|
+
) ||
|
|
1007
|
+
null}
|
|
1008
|
+
<li
|
|
1009
|
+
class={liSelectedClass}
|
|
1010
|
+
role="option"
|
|
1011
|
+
aria-selected="true"
|
|
1012
|
+
animate:flip={selectedFlipParams}
|
|
1013
|
+
draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
|
|
1014
|
+
ondragstart={dragstart(idx)}
|
|
1015
|
+
ondragover={(event) => {
|
|
1016
|
+
event.preventDefault() // needed for ondrop to fire
|
|
1017
|
+
}}
|
|
1018
|
+
ondrop={drop(idx)}
|
|
1019
|
+
ondragenter={() => (drag_idx = idx)}
|
|
1020
|
+
class:active={drag_idx === idx}
|
|
1021
|
+
style={selectedOptionStyle}
|
|
1022
|
+
onmouseup={(event) => event.stopPropagation()}
|
|
1023
|
+
>
|
|
1024
|
+
{#if selectedItem}
|
|
1025
|
+
{@render selectedItem({
|
|
1026
|
+
option,
|
|
1027
|
+
idx,
|
|
1028
|
+
})}
|
|
1029
|
+
{:else if children}
|
|
1030
|
+
{@render children({
|
|
1031
|
+
option,
|
|
1032
|
+
idx,
|
|
1033
|
+
})}
|
|
1034
|
+
{:else if parseLabelsAsHtml}
|
|
1035
|
+
{@html get_label(option)}
|
|
1036
|
+
{:else}
|
|
1037
|
+
{get_label(option)}
|
|
1038
|
+
{/if}
|
|
1039
|
+
{#if !disabled && can_remove}
|
|
1040
|
+
<button
|
|
1041
|
+
onclick={(event) => remove(option, event)}
|
|
1042
|
+
onkeydown={if_enter_or_space((event) => remove(option, event))}
|
|
1043
|
+
type="button"
|
|
1044
|
+
title="{removeBtnTitle} {get_label(option)}"
|
|
1045
|
+
class="remove"
|
|
1046
|
+
>
|
|
1047
|
+
{#if removeIcon}
|
|
1048
|
+
{@render removeIcon()}
|
|
1049
|
+
{:else}
|
|
1050
|
+
<Icon icon="Cross" style="width: 15px" />
|
|
1051
|
+
{/if}
|
|
1052
|
+
</button>
|
|
1053
|
+
{/if}
|
|
1054
|
+
</li>
|
|
1055
|
+
{/each}
|
|
1056
|
+
<input
|
|
1057
|
+
class={inputClass}
|
|
1058
|
+
style={inputStyle}
|
|
1059
|
+
bind:this={input}
|
|
1060
|
+
bind:value={searchText}
|
|
1061
|
+
{id}
|
|
1062
|
+
{disabled}
|
|
1063
|
+
{autocomplete}
|
|
1064
|
+
{inputmode}
|
|
1065
|
+
{pattern}
|
|
1066
|
+
placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
|
|
1067
|
+
role="combobox"
|
|
1068
|
+
aria-haspopup="listbox"
|
|
1069
|
+
aria-expanded={open}
|
|
1070
|
+
aria-controls={listbox_id}
|
|
1071
|
+
aria-activedescendant={active_option_id}
|
|
1072
|
+
aria-busy={loading || load_options_loading || null}
|
|
1073
|
+
aria-invalid={invalid ? `true` : null}
|
|
1074
|
+
ondrop={() => false}
|
|
1075
|
+
onmouseup={open_dropdown}
|
|
1076
|
+
onkeydown={handle_input_keydown}
|
|
1077
|
+
onfocus={handle_input_focus}
|
|
1078
|
+
onblur={handle_input_blur}
|
|
1079
|
+
{onclick}
|
|
1080
|
+
{onkeyup}
|
|
1081
|
+
{onmousedown}
|
|
1082
|
+
{onmouseenter}
|
|
1083
|
+
{onmouseleave}
|
|
1084
|
+
{ontouchcancel}
|
|
1085
|
+
{ontouchend}
|
|
1086
|
+
{ontouchmove}
|
|
1087
|
+
{ontouchstart}
|
|
1088
|
+
{...rest}
|
|
1089
|
+
/>
|
|
1090
|
+
{@render afterInput?.({
|
|
1091
|
+
selected,
|
|
1092
|
+
disabled,
|
|
1093
|
+
invalid,
|
|
1094
|
+
id,
|
|
1095
|
+
placeholder: placeholder_text,
|
|
1096
|
+
open,
|
|
1097
|
+
required,
|
|
1098
|
+
})}
|
|
1099
|
+
</ul>
|
|
1100
|
+
{#if loading}
|
|
1101
|
+
{#if spinner}
|
|
1102
|
+
{@render spinner()}
|
|
1103
|
+
{:else}
|
|
1104
|
+
<CircleSpinner />
|
|
1105
|
+
{/if}
|
|
1106
|
+
{/if}
|
|
1107
|
+
{#if disabled}
|
|
1108
|
+
{#if disabledIcon}
|
|
1109
|
+
{@render disabledIcon()}
|
|
1110
|
+
{:else}
|
|
1111
|
+
<Icon
|
|
1112
|
+
icon="Disabled"
|
|
1113
|
+
style="width: 14pt; margin: 0 2pt"
|
|
1114
|
+
data-name="disabled-icon"
|
|
1115
|
+
aria-disabled="true"
|
|
1116
|
+
/>
|
|
1117
|
+
{/if}
|
|
1118
|
+
{:else if selected.length > 0}
|
|
1119
|
+
{#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
|
|
1120
|
+
<Wiggle bind:wiggle angle={20}>
|
|
1121
|
+
<span class="max-select-msg {maxSelectMsgClass}">
|
|
1122
|
+
{maxSelectMsg?.(selected.length, maxSelect)}
|
|
1123
|
+
</span>
|
|
1124
|
+
</Wiggle>
|
|
1125
|
+
{/if}
|
|
1126
|
+
{#if maxSelect !== 1 && selected.length > 1}
|
|
1127
|
+
<button
|
|
1128
|
+
type="button"
|
|
1129
|
+
class="remove remove-all"
|
|
1130
|
+
title={removeAllTitle}
|
|
1131
|
+
onclick={remove_all}
|
|
1132
|
+
onkeydown={if_enter_or_space(remove_all)}
|
|
1133
|
+
>
|
|
1134
|
+
{#if removeIcon}
|
|
1135
|
+
{@render removeIcon()}
|
|
1136
|
+
{:else}
|
|
1137
|
+
<Icon icon="Cross" style="width: 15px" />
|
|
1138
|
+
{/if}
|
|
1139
|
+
</button>
|
|
1140
|
+
{/if}
|
|
1141
|
+
{/if}
|
|
1142
|
+
|
|
1143
|
+
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
|
|
1144
|
+
{#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
|
|
1145
|
+
loadOptions}
|
|
1146
|
+
<ul
|
|
1147
|
+
use:portal={{ target_node: outerDiv, ...portal_params }}
|
|
1148
|
+
{@attach highlight_matches({
|
|
1149
|
+
query: searchText,
|
|
1150
|
+
disabled: !highlightMatches,
|
|
1151
|
+
fuzzy,
|
|
1152
|
+
css_class: `sms-search-matches`,
|
|
1153
|
+
// don't highlight text in the "Create this option..." message
|
|
1154
|
+
node_filter: (node) =>
|
|
1155
|
+
node?.parentElement?.closest(`li.user-msg`)
|
|
1156
|
+
? NodeFilter.FILTER_REJECT
|
|
1157
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
1158
|
+
})}
|
|
1159
|
+
id={listbox_id}
|
|
1160
|
+
class:hidden={!open}
|
|
1161
|
+
class="options {ulOptionsClass}"
|
|
1162
|
+
role="listbox"
|
|
1163
|
+
aria-multiselectable={maxSelect === null || maxSelect > 1}
|
|
1164
|
+
aria-expanded={open}
|
|
1165
|
+
aria-disabled={disabled ? `true` : null}
|
|
1166
|
+
bind:this={ul_options}
|
|
1167
|
+
style={ulOptionsStyle}
|
|
1168
|
+
onscroll={handle_options_scroll}
|
|
1169
|
+
onmousemove={() => (ignore_hover = false)}
|
|
1170
|
+
>
|
|
1171
|
+
{#if selectAllOption && effective_options.length > 0 &&
|
|
1172
|
+
(maxSelect === null || maxSelect > 1)}
|
|
1173
|
+
{@const label = typeof selectAllOption === `string` ? selectAllOption : `Select all`}
|
|
1174
|
+
<li
|
|
1175
|
+
class="select-all {liSelectAllClass}"
|
|
1176
|
+
onclick={select_all}
|
|
1177
|
+
onkeydown={if_enter_or_space(select_all)}
|
|
1178
|
+
role="option"
|
|
1179
|
+
aria-selected="false"
|
|
1180
|
+
tabindex="0"
|
|
1181
|
+
>
|
|
1182
|
+
{label}
|
|
1183
|
+
</li>
|
|
1184
|
+
{/if}
|
|
1185
|
+
{#each grouped_options as
|
|
1186
|
+
{ group: group_name, options: group_opts, collapsed },
|
|
1187
|
+
group_idx
|
|
1188
|
+
(group_name ?? `ungrouped-${group_idx}`)
|
|
1189
|
+
}
|
|
1190
|
+
{#if group_name !== null}
|
|
1191
|
+
{@const { all_selected, selected_count } = group_header_state.get(group_name) ??
|
|
1192
|
+
{ all_selected: false, selected_count: 0 }}
|
|
1193
|
+
{@const handle_toggle = () =>
|
|
1194
|
+
collapsibleGroups && toggle_group_collapsed(group_name)}
|
|
1195
|
+
{@const handle_group_select = (event: Event) =>
|
|
1196
|
+
toggle_group_selection(group_opts, collapsed, all_selected, event)}
|
|
1197
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
1198
|
+
<li
|
|
1199
|
+
class="group-header {liGroupHeaderClass}"
|
|
1200
|
+
class:collapsible={collapsibleGroups}
|
|
1201
|
+
class:sticky={stickyGroupHeaders}
|
|
1202
|
+
role={collapsibleGroups ? `button` : `presentation`}
|
|
1203
|
+
aria-expanded={collapsibleGroups ? !collapsed : undefined}
|
|
1204
|
+
aria-label="Group: {group_name}"
|
|
1205
|
+
style={liGroupHeaderStyle}
|
|
1206
|
+
onclick={handle_toggle}
|
|
1207
|
+
onkeydown={if_enter_or_space(handle_toggle)}
|
|
1208
|
+
tabindex={collapsibleGroups ? 0 : -1}
|
|
1209
|
+
>
|
|
1210
|
+
{#if groupHeader}
|
|
1211
|
+
{@render groupHeader({ group: group_name, options: group_opts, collapsed })}
|
|
1212
|
+
{:else}
|
|
1213
|
+
<span class="group-label">{group_name}</span>
|
|
1214
|
+
<span class="group-count">
|
|
1215
|
+
{#if keepSelectedInDropdown && selected_count > 0}
|
|
1216
|
+
({selected_count}/{group_opts.length})
|
|
1217
|
+
{:else}
|
|
1218
|
+
({group_opts.length})
|
|
1219
|
+
{/if}
|
|
1220
|
+
</span>
|
|
1221
|
+
{#if groupSelectAll && (maxSelect === null || maxSelect > 1)}
|
|
1222
|
+
<button
|
|
1223
|
+
type="button"
|
|
1224
|
+
class="group-select-all"
|
|
1225
|
+
class:deselect={all_selected}
|
|
1226
|
+
onclick={handle_group_select}
|
|
1227
|
+
onkeydown={if_enter_or_space(handle_group_select)}
|
|
1228
|
+
>
|
|
1229
|
+
{all_selected ? `Deselect all` : `Select all`}
|
|
1230
|
+
</button>
|
|
1231
|
+
{/if}
|
|
1232
|
+
{#if collapsibleGroups}
|
|
1233
|
+
<Icon
|
|
1234
|
+
icon={collapsed ? `ChevronRight` : `ChevronDown`}
|
|
1235
|
+
style="width: 12px; margin-left: auto"
|
|
1236
|
+
/>
|
|
1237
|
+
{/if}
|
|
1238
|
+
{/if}
|
|
1239
|
+
</li>
|
|
1240
|
+
{/if}
|
|
1241
|
+
{#if !collapsed || !collapsibleGroups}
|
|
1242
|
+
{#each group_opts as
|
|
1243
|
+
option_item,
|
|
1244
|
+
local_idx
|
|
1245
|
+
(duplicates
|
|
1246
|
+
? `${key(option_item)}-${group_idx}-${local_idx}`
|
|
1247
|
+
: key(option_item))
|
|
1248
|
+
}
|
|
1249
|
+
{@const flat_idx = navigable_index_map.get(option_item) ?? -1}
|
|
1250
|
+
{@const {
|
|
1251
|
+
label,
|
|
1252
|
+
disabled = null,
|
|
1253
|
+
title = null,
|
|
1254
|
+
selectedTitle = null,
|
|
1255
|
+
disabledTitle = defaultDisabledTitle,
|
|
1256
|
+
} = is_object(option_item) ? option_item : { label: option_item }}
|
|
1257
|
+
{@const active = activeIndex === flat_idx && flat_idx >= 0}
|
|
1258
|
+
{@const selected = is_selected(label)}
|
|
1259
|
+
{@const optionStyle =
|
|
1260
|
+
[get_style(option_item, `option`), liOptionStyle].filter(Boolean).join(
|
|
1261
|
+
` `,
|
|
1262
|
+
) ||
|
|
1263
|
+
null}
|
|
1264
|
+
{#if is_option_visible(flat_idx)}
|
|
1265
|
+
<li
|
|
1266
|
+
id="{internal_id}-opt-{flat_idx}"
|
|
1267
|
+
onclick={(event) => handle_option_interact(option_item, disabled, event)}
|
|
1268
|
+
title={disabled ? disabledTitle : (selected && selectedTitle) || title}
|
|
1269
|
+
class:selected
|
|
1270
|
+
class:active
|
|
1271
|
+
class:disabled
|
|
1272
|
+
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
1273
|
+
onmouseover={() => {
|
|
1274
|
+
if (!disabled && !ignore_hover) activeIndex = flat_idx
|
|
1275
|
+
}}
|
|
1276
|
+
onfocus={() => {
|
|
1277
|
+
if (!disabled) activeIndex = flat_idx
|
|
1278
|
+
}}
|
|
1279
|
+
role="option"
|
|
1280
|
+
aria-selected={selected ? `true` : `false`}
|
|
1281
|
+
aria-posinset={flat_idx + 1}
|
|
1282
|
+
aria-setsize={navigable_options.length}
|
|
1283
|
+
style={optionStyle}
|
|
1284
|
+
onkeydown={if_enter_or_space((event) =>
|
|
1285
|
+
handle_option_interact(option_item, disabled, event)
|
|
1286
|
+
)}
|
|
1287
|
+
>
|
|
1288
|
+
{#if keepSelectedInDropdown === `checkboxes`}
|
|
1289
|
+
<input
|
|
1290
|
+
type="checkbox"
|
|
1291
|
+
class="option-checkbox"
|
|
1292
|
+
checked={selected}
|
|
1293
|
+
aria-label="Toggle {get_label(option_item)}"
|
|
1294
|
+
tabindex="-1"
|
|
1295
|
+
/>
|
|
1296
|
+
{/if}
|
|
1297
|
+
{#if option}
|
|
1298
|
+
{@render option({
|
|
1299
|
+
option: option_item,
|
|
1300
|
+
idx: flat_idx,
|
|
1301
|
+
})}
|
|
1302
|
+
{:else if children}
|
|
1303
|
+
{@render children({
|
|
1304
|
+
option: option_item,
|
|
1305
|
+
idx: flat_idx,
|
|
1306
|
+
})}
|
|
1307
|
+
{:else if parseLabelsAsHtml}
|
|
1308
|
+
{@html get_label(option_item)}
|
|
1309
|
+
{:else}
|
|
1310
|
+
{get_label(option_item)}
|
|
1311
|
+
{/if}
|
|
1312
|
+
</li>
|
|
1313
|
+
{/if}
|
|
1314
|
+
{/each}
|
|
1315
|
+
{/if}
|
|
1316
|
+
{/each}
|
|
1317
|
+
{#if searchText}
|
|
1318
|
+
{@const text_input_is_duplicate = selected_labels.includes(searchText)}
|
|
1319
|
+
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
|
|
1320
|
+
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
|
|
1321
|
+
{@const no_match =
|
|
1322
|
+
Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
|
|
1323
|
+
`no-match`}
|
|
1324
|
+
{@const msgType = is_dupe || can_create || no_match}
|
|
1325
|
+
{@const msg = msgType && {
|
|
1326
|
+
dupe: duplicateOptionMsg,
|
|
1327
|
+
create: createOptionMsg,
|
|
1328
|
+
'no-match': noMatchingOptionsMsg,
|
|
1329
|
+
}[msgType]}
|
|
1330
|
+
{@const can_add_user_option = msgType === `create` && allowUserOptions}
|
|
1331
|
+
{@const handle_create = (event: Event) =>
|
|
1332
|
+
can_add_user_option && add(searchText as Option, event)}
|
|
1333
|
+
{#if msg}
|
|
1334
|
+
<li
|
|
1335
|
+
onclick={handle_create}
|
|
1336
|
+
onkeydown={can_add_user_option ? if_enter_or_space(handle_create) : undefined}
|
|
1337
|
+
title={msgType === `create`
|
|
1338
|
+
? createOptionMsg
|
|
1339
|
+
: msgType === `dupe`
|
|
1340
|
+
? duplicateOptionMsg
|
|
1341
|
+
: ``}
|
|
1342
|
+
class:active={option_msg_is_active}
|
|
1343
|
+
onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
|
|
1344
|
+
onfocus={() => (option_msg_is_active = true)}
|
|
1345
|
+
onmouseout={() => (option_msg_is_active = false)}
|
|
1346
|
+
onblur={() => (option_msg_is_active = false)}
|
|
1347
|
+
role="option"
|
|
1348
|
+
aria-selected="false"
|
|
1349
|
+
class="
|
|
1350
|
+
user-msg {liUserMsgClass} {option_msg_is_active
|
|
1351
|
+
? liActiveUserMsgClass
|
|
1352
|
+
: ``}
|
|
1353
|
+
"
|
|
1354
|
+
style:cursor={{
|
|
1355
|
+
dupe: `not-allowed`,
|
|
1356
|
+
create: `pointer`,
|
|
1357
|
+
'no-match': `default`,
|
|
1358
|
+
}[msgType]}
|
|
1359
|
+
>
|
|
1360
|
+
{#if userMsg}
|
|
1361
|
+
{@render userMsg({ searchText, msgType, msg })}
|
|
1362
|
+
{:else}
|
|
1363
|
+
{msg}
|
|
1364
|
+
{/if}
|
|
1365
|
+
</li>
|
|
1366
|
+
{/if}
|
|
1367
|
+
{/if}
|
|
1368
|
+
{#if loadOptions && load_options_loading}
|
|
1369
|
+
<li class="loading-more" role="status" aria-label="Loading more options">
|
|
1370
|
+
<CircleSpinner />
|
|
1371
|
+
</li>
|
|
1372
|
+
{/if}
|
|
1373
|
+
</ul>
|
|
1374
|
+
{/if}
|
|
1375
|
+
<!-- Screen reader announcements for dropdown state, option count, and selection changes -->
|
|
1376
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">
|
|
1377
|
+
{#if last_action}
|
|
1378
|
+
{#if last_action.type === `add`}
|
|
1379
|
+
{last_action.label} selected
|
|
1380
|
+
{:else if last_action.type === `remove`}
|
|
1381
|
+
{last_action.label} removed
|
|
1382
|
+
{:else if last_action.type === `removeAll`}
|
|
1383
|
+
{last_action.label} removed
|
|
1384
|
+
{/if}
|
|
1385
|
+
{:else if open}
|
|
1386
|
+
{matchingOptions.length} option{matchingOptions.length === 1 ? `` : `s`} available
|
|
1387
|
+
{/if}
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
|
|
1391
|
+
<style>
|
|
1392
|
+
/* Screen reader only - visually hidden but accessible to assistive technology */
|
|
1393
|
+
.sr-only {
|
|
1394
|
+
position: absolute;
|
|
1395
|
+
width: 1px;
|
|
1396
|
+
height: 1px;
|
|
1397
|
+
padding: 0;
|
|
1398
|
+
margin: -1px;
|
|
1399
|
+
overflow: hidden;
|
|
1400
|
+
clip: rect(0, 0, 0, 0);
|
|
1401
|
+
white-space: nowrap;
|
|
1402
|
+
border: 0;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/* Use :where() for elements with user-overridable class props (outerDivClass, ulSelectedClass, liSelectedClass)
|
|
1406
|
+
so user-provided classes take precedence. See: https://github.com/janosh/svelte-multiselect/issues/380 */
|
|
1407
|
+
:where(div.multiselect) {
|
|
1408
|
+
position: relative;
|
|
1409
|
+
align-items: center;
|
|
1410
|
+
display: flex;
|
|
1411
|
+
cursor: text;
|
|
1412
|
+
box-sizing: border-box;
|
|
1413
|
+
border: var(--sms-border, 1pt solid light-dark(lightgray, #555));
|
|
1414
|
+
border-radius: var(--sms-border-radius, 3pt);
|
|
1415
|
+
background: var(--sms-bg, light-dark(white, #1a1a1a));
|
|
1416
|
+
width: var(--sms-width);
|
|
1417
|
+
max-width: var(--sms-max-width);
|
|
1418
|
+
padding: var(--sms-padding, 0 3pt);
|
|
1419
|
+
color: var(--sms-text-color);
|
|
1420
|
+
font-size: var(--sms-font-size, inherit);
|
|
1421
|
+
min-height: var(--sms-min-height, 22pt);
|
|
1422
|
+
margin: var(--sms-margin);
|
|
1423
|
+
}
|
|
1424
|
+
:where(div.multiselect.open) {
|
|
1425
|
+
/* increase z-index when open to ensure the dropdown of one <MultiSelect />
|
|
1426
|
+
displays above that of another slightly below it on the page */
|
|
1427
|
+
z-index: var(--sms-open-z-index, 4);
|
|
1428
|
+
}
|
|
1429
|
+
:where(div.multiselect:focus-within) {
|
|
1430
|
+
border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
|
|
1431
|
+
}
|
|
1432
|
+
:where(div.multiselect.disabled) {
|
|
1433
|
+
background: var(--sms-disabled-bg, light-dark(lightgray, #444));
|
|
1434
|
+
cursor: not-allowed;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
:where(div.multiselect > ul.selected) {
|
|
1438
|
+
display: flex;
|
|
1439
|
+
flex: 1;
|
|
1440
|
+
padding: 0;
|
|
1441
|
+
margin: 0;
|
|
1442
|
+
flex-wrap: wrap;
|
|
1443
|
+
}
|
|
1444
|
+
:where(div.multiselect > ul.selected > li) {
|
|
1445
|
+
align-items: center;
|
|
1446
|
+
border-radius: 3pt;
|
|
1447
|
+
display: flex;
|
|
1448
|
+
margin: 2pt;
|
|
1449
|
+
line-height: normal;
|
|
1450
|
+
transition: 0.3s;
|
|
1451
|
+
white-space: nowrap;
|
|
1452
|
+
background: var(
|
|
1453
|
+
--sms-selected-bg,
|
|
1454
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1455
|
+
);
|
|
1456
|
+
padding: var(--sms-selected-li-padding, 1pt 5pt);
|
|
1457
|
+
color: var(--sms-selected-text-color, var(--sms-text-color));
|
|
1458
|
+
}
|
|
1459
|
+
:where(div.multiselect > ul.selected > li[draggable='true']) {
|
|
1460
|
+
cursor: grab;
|
|
1461
|
+
}
|
|
1462
|
+
:where(div.multiselect > ul.selected > li.active) {
|
|
1463
|
+
background: var(
|
|
1464
|
+
--sms-li-active-bg,
|
|
1465
|
+
var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
:is(div.multiselect button) {
|
|
1469
|
+
border-radius: 50%;
|
|
1470
|
+
aspect-ratio: 1; /* ensure circle, not ellipse */
|
|
1471
|
+
display: flex;
|
|
1472
|
+
transition: 0.2s;
|
|
1473
|
+
color: inherit;
|
|
1474
|
+
background: transparent;
|
|
1475
|
+
border: none;
|
|
1476
|
+
cursor: pointer;
|
|
1477
|
+
outline: none;
|
|
1478
|
+
padding: 1pt;
|
|
1479
|
+
margin: 0 0 0 3pt; /* CSS reset */
|
|
1480
|
+
}
|
|
1481
|
+
:is(div.multiselect button.remove-all) {
|
|
1482
|
+
margin: 0 3pt;
|
|
1483
|
+
}
|
|
1484
|
+
:is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
|
|
1485
|
+
color: var(--sms-remove-btn-hover-color, light-dark(#0088cc, lightskyblue));
|
|
1486
|
+
background: var(
|
|
1487
|
+
--sms-remove-btn-hover-bg,
|
|
1488
|
+
light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2))
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
:is(div.multiselect input) {
|
|
1493
|
+
margin: auto 0; /* CSS reset */
|
|
1494
|
+
padding: 0; /* CSS reset */
|
|
1495
|
+
}
|
|
1496
|
+
:where(div.multiselect > ul.selected > input) {
|
|
1497
|
+
border: none;
|
|
1498
|
+
outline: none;
|
|
1499
|
+
background: none;
|
|
1500
|
+
flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
|
|
1501
|
+
min-width: 2em;
|
|
1502
|
+
/* ensure input uses text color and not --sms-selected-text-color */
|
|
1503
|
+
color: var(--sms-text-color);
|
|
1504
|
+
font-size: inherit;
|
|
1505
|
+
cursor: inherit; /* needed for disabled state */
|
|
1506
|
+
border-radius: 0; /* reset ul.selected > li */
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
|
|
1510
|
+
:where(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
|
|
1511
|
+
min-width: 1px; /* Minimal width to remain interactive */
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
|
|
1515
|
+
div.multiselect > ul.selected > input::placeholder {
|
|
1516
|
+
padding-left: 5pt;
|
|
1517
|
+
color: var(--sms-placeholder-color);
|
|
1518
|
+
opacity: var(--sms-placeholder-opacity);
|
|
1519
|
+
}
|
|
1520
|
+
:is(div.multiselect > input.form-control) {
|
|
1521
|
+
width: 2em;
|
|
1522
|
+
position: absolute;
|
|
1523
|
+
background: transparent;
|
|
1524
|
+
border: none;
|
|
1525
|
+
outline: none;
|
|
1526
|
+
z-index: -1;
|
|
1527
|
+
opacity: 0;
|
|
1528
|
+
pointer-events: none;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/* Use :where() for ul.options elements with class props (ulOptionsClass, liOptionClass, liUserMsgClass) */
|
|
1532
|
+
:where(ul.options) {
|
|
1533
|
+
list-style: none;
|
|
1534
|
+
/* top, left, width, position are managed by portal when active */
|
|
1535
|
+
/* but provide defaults for non-portaled or initial state */
|
|
1536
|
+
position: absolute; /* Default, overridden by portal to fixed when open */
|
|
1537
|
+
top: 100%;
|
|
1538
|
+
left: 0;
|
|
1539
|
+
width: 100%;
|
|
1540
|
+
/* Default z-index if not portaled/overridden by portal */
|
|
1541
|
+
z-index: var(--sms-options-z-index, 3);
|
|
1542
|
+
|
|
1543
|
+
overflow: auto;
|
|
1544
|
+
transition: all
|
|
1545
|
+
0.2s; /* Consider if this transition is desirable with portal positioning */
|
|
1546
|
+
box-sizing: border-box;
|
|
1547
|
+
background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
|
|
1548
|
+
max-height: var(--sms-options-max-height, 50vh);
|
|
1549
|
+
overscroll-behavior: var(--sms-options-overscroll, none);
|
|
1550
|
+
box-shadow: var(
|
|
1551
|
+
--sms-options-shadow,
|
|
1552
|
+
light-dark(0 0 14pt -8pt black, 0 0 14pt -4pt rgba(0, 0, 0, 0.8))
|
|
1553
|
+
);
|
|
1554
|
+
border: var(--sms-options-border);
|
|
1555
|
+
border-width: var(--sms-options-border-width);
|
|
1556
|
+
border-radius: var(--sms-options-border-radius, 1ex);
|
|
1557
|
+
padding: var(--sms-options-padding);
|
|
1558
|
+
margin: var(--sms-options-margin, 6pt 0 0 0);
|
|
1559
|
+
}
|
|
1560
|
+
:where(ul.options.hidden) {
|
|
1561
|
+
visibility: hidden;
|
|
1562
|
+
opacity: 0;
|
|
1563
|
+
transform: translateY(50px);
|
|
1564
|
+
pointer-events: none;
|
|
1565
|
+
}
|
|
1566
|
+
:where(ul.options > li) {
|
|
1567
|
+
padding: 3pt 1ex;
|
|
1568
|
+
cursor: pointer;
|
|
1569
|
+
scroll-margin: var(--sms-options-scroll-margin, 100px);
|
|
1570
|
+
border-left: 3px solid transparent;
|
|
1571
|
+
}
|
|
1572
|
+
:where(ul.options .user-msg) {
|
|
1573
|
+
/* block needed so vertical padding applies to span */
|
|
1574
|
+
display: block;
|
|
1575
|
+
padding: 3pt 2ex;
|
|
1576
|
+
}
|
|
1577
|
+
:where(ul.options > li.selected) {
|
|
1578
|
+
background: var(
|
|
1579
|
+
--sms-li-selected-plain-bg,
|
|
1580
|
+
light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))
|
|
1581
|
+
);
|
|
1582
|
+
border-left: var(
|
|
1583
|
+
--sms-li-selected-plain-border,
|
|
1584
|
+
3px solid var(--sms-active-color, cornflowerblue)
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
:where(ul.options > li.active) {
|
|
1588
|
+
background: var(
|
|
1589
|
+
--sms-li-active-bg,
|
|
1590
|
+
var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
:where(ul.options > li.disabled) {
|
|
1594
|
+
cursor: not-allowed;
|
|
1595
|
+
background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a));
|
|
1596
|
+
color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666));
|
|
1597
|
+
}
|
|
1598
|
+
/* Checkbox styling for keepSelectedInDropdown='checkboxes' mode - internal, no class prop */
|
|
1599
|
+
:is(ul.options > li > input.option-checkbox) {
|
|
1600
|
+
width: 16px;
|
|
1601
|
+
height: 16px;
|
|
1602
|
+
margin-right: 6px;
|
|
1603
|
+
accent-color: var(--sms-active-color, cornflowerblue);
|
|
1604
|
+
}
|
|
1605
|
+
/* Select all option styling - has liSelectAllClass prop */
|
|
1606
|
+
:where(ul.options > li.select-all) {
|
|
1607
|
+
border-bottom: var(
|
|
1608
|
+
--sms-select-all-border-bottom,
|
|
1609
|
+
1px solid light-dark(lightgray, #555)
|
|
1610
|
+
);
|
|
1611
|
+
font-weight: var(--sms-select-all-font-weight, 500);
|
|
1612
|
+
color: var(--sms-select-all-color, inherit);
|
|
1613
|
+
background: var(--sms-select-all-bg, transparent);
|
|
1614
|
+
margin-bottom: var(--sms-select-all-margin-bottom, 2pt);
|
|
1615
|
+
}
|
|
1616
|
+
:where(ul.options > li.select-all:hover) {
|
|
1617
|
+
background: var(
|
|
1618
|
+
--sms-select-all-hover-bg,
|
|
1619
|
+
var(
|
|
1620
|
+
--sms-li-active-bg,
|
|
1621
|
+
var(
|
|
1622
|
+
--sms-active-color,
|
|
1623
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1624
|
+
)
|
|
1625
|
+
)
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
/* Group header styling - has liGroupHeaderClass prop */
|
|
1629
|
+
:where(ul.options > li.group-header) {
|
|
1630
|
+
display: flex;
|
|
1631
|
+
align-items: center;
|
|
1632
|
+
font-weight: var(--sms-group-header-font-weight, 600);
|
|
1633
|
+
font-size: var(--sms-group-header-font-size, 0.85em);
|
|
1634
|
+
color: var(--sms-group-header-color, light-dark(#666, #aaa));
|
|
1635
|
+
background: var(--sms-group-header-bg, transparent);
|
|
1636
|
+
padding: var(--sms-group-header-padding, 6pt 1ex 3pt);
|
|
1637
|
+
cursor: default;
|
|
1638
|
+
border-left: none;
|
|
1639
|
+
text-transform: var(--sms-group-header-text-transform, uppercase);
|
|
1640
|
+
letter-spacing: var(--sms-group-header-letter-spacing, 0.5px);
|
|
1641
|
+
}
|
|
1642
|
+
:where(ul.options > li.group-header:not(:first-child)) {
|
|
1643
|
+
margin-top: var(--sms-group-header-margin-top, 4pt);
|
|
1644
|
+
border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
|
|
1645
|
+
}
|
|
1646
|
+
:where(ul.options > li.group-header.collapsible) {
|
|
1647
|
+
cursor: pointer;
|
|
1648
|
+
}
|
|
1649
|
+
:where(ul.options > li.group-header.collapsible:hover) {
|
|
1650
|
+
background: var(
|
|
1651
|
+
--sms-group-header-hover-bg,
|
|
1652
|
+
light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
/* Internal elements without class props - keep :is() for specificity */
|
|
1656
|
+
:is(ul.options > li.group-header .group-label) {
|
|
1657
|
+
flex: 1;
|
|
1658
|
+
}
|
|
1659
|
+
:is(ul.options > li.group-header .group-count) {
|
|
1660
|
+
opacity: 0.6;
|
|
1661
|
+
font-size: 0.9em;
|
|
1662
|
+
font-weight: normal;
|
|
1663
|
+
margin-left: 4pt;
|
|
1664
|
+
}
|
|
1665
|
+
/* Sticky group headers when enabled */
|
|
1666
|
+
:where(ul.options > li.group-header.sticky) {
|
|
1667
|
+
position: sticky;
|
|
1668
|
+
top: 0;
|
|
1669
|
+
z-index: 1;
|
|
1670
|
+
background: var(
|
|
1671
|
+
--sms-group-header-sticky-bg,
|
|
1672
|
+
var(--sms-options-bg, light-dark(#fafafa, #1a1a1a))
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
/* Indent grouped options for visual hierarchy */
|
|
1676
|
+
:where(
|
|
1677
|
+
ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more)
|
|
1678
|
+
) {
|
|
1679
|
+
padding-left: var(
|
|
1680
|
+
--sms-group-item-padding-left,
|
|
1681
|
+
var(--sms-group-option-indent, 1.5ex)
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
/* Collapse/expand animation for group chevron icon - internal, keep :is() for specificity */
|
|
1685
|
+
:is(ul.options > li.group-header) :global(svg) {
|
|
1686
|
+
transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out;
|
|
1687
|
+
}
|
|
1688
|
+
/* Keep :is() for internal buttons without class props */
|
|
1689
|
+
:is(ul.options > li.group-header button.group-select-all) {
|
|
1690
|
+
font-size: 0.9em;
|
|
1691
|
+
font-weight: normal;
|
|
1692
|
+
text-transform: none;
|
|
1693
|
+
color: var(--sms-active-color, cornflowerblue);
|
|
1694
|
+
background: transparent;
|
|
1695
|
+
border: none;
|
|
1696
|
+
cursor: pointer;
|
|
1697
|
+
padding: 2pt 4pt;
|
|
1698
|
+
margin-left: 8pt;
|
|
1699
|
+
border-radius: 3pt;
|
|
1700
|
+
aspect-ratio: auto; /* override global button aspect-ratio: 1 */
|
|
1701
|
+
}
|
|
1702
|
+
:is(ul.options > li.group-header button.group-select-all:hover) {
|
|
1703
|
+
background: var(
|
|
1704
|
+
--sms-group-select-all-hover-bg,
|
|
1705
|
+
light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
:is(ul.options > li.group-header button.group-select-all.deselect) {
|
|
1709
|
+
color: var(--sms-group-deselect-color, light-dark(#c44, #f77));
|
|
1710
|
+
}
|
|
1711
|
+
:where(span.max-select-msg) {
|
|
1712
|
+
padding: 0 3pt;
|
|
1713
|
+
}
|
|
1714
|
+
::highlight(sms-search-matches) {
|
|
1715
|
+
color: light-dark(#1a8870, mediumaquamarine);
|
|
1716
|
+
}
|
|
1717
|
+
/* Loading more indicator for infinite scrolling - internal, no class prop */
|
|
1718
|
+
:is(ul.options > li.loading-more) {
|
|
1719
|
+
display: flex;
|
|
1720
|
+
justify-content: center;
|
|
1721
|
+
align-items: center;
|
|
1722
|
+
padding: 8pt;
|
|
1723
|
+
cursor: default;
|
|
1724
|
+
}
|
|
1725
|
+
</style>
|