svelte-multiselect 11.5.2 → 11.6.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.d.ts +2 -2
- package/dist/MultiSelect.svelte +231 -102
- package/dist/MultiSelect.svelte.d.ts +2 -2
- package/dist/Nav.svelte +124 -61
- package/dist/Nav.svelte.d.ts +2 -1
- package/dist/attachments.d.ts +3 -0
- package/dist/attachments.js +145 -37
- package/dist/heading-anchors.js +10 -16
- package/dist/live-examples/highlighter.js +35 -0
- package/dist/live-examples/index.d.ts +7 -0
- package/dist/live-examples/index.js +23 -0
- package/dist/live-examples/mdsvex-transform.d.ts +32 -0
- package/dist/live-examples/mdsvex-transform.js +185 -0
- package/dist/live-examples/vite-plugin.d.ts +6 -0
- package/dist/live-examples/vite-plugin.js +170 -0
- package/dist/types.d.ts +16 -1
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +15 -0
- package/package.json +37 -14
package/dist/MultiSelect.svelte
CHANGED
|
@@ -5,17 +5,17 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
|
5
5
|
import { highlight_matches } from './attachments';
|
|
6
6
|
import CircleSpinner from './CircleSpinner.svelte';
|
|
7
7
|
import Icon from './Icon.svelte';
|
|
8
|
-
import
|
|
8
|
+
import * as utils from './utils';
|
|
9
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) =>
|
|
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) => utils.get_option_key(opt), filterFunc = (opt, searchText) => {
|
|
11
11
|
if (!searchText)
|
|
12
12
|
return true;
|
|
13
|
-
const label = `${get_label(opt)}`;
|
|
13
|
+
const label = `${utils.get_label(opt)}`;
|
|
14
14
|
return fuzzy
|
|
15
|
-
? fuzzy_match(searchText, label)
|
|
15
|
+
? utils.fuzzy_match(searchText, label)
|
|
16
16
|
: label.toLowerCase().includes(searchText.toLowerCase());
|
|
17
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
|
-
?
|
|
18
|
+
? Array.isArray(value) ? value : [value]
|
|
19
19
|
: (options
|
|
20
20
|
?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
|
|
21
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 = {},
|
|
@@ -28,22 +28,22 @@ selectedFlipParams = { duration: 100 },
|
|
|
28
28
|
// Option grouping feature
|
|
29
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
30
|
// Keyboard shortcuts for common actions
|
|
31
|
-
shortcuts = {},
|
|
31
|
+
shortcuts = {},
|
|
32
|
+
// Selection history for undo/redo (enabled by default, set to false or 0 to disable)
|
|
33
|
+
history = true, undo = $bindable(), redo = $bindable(), canUndo = $bindable(false), canRedo = $bindable(false), onundo, onredo, ...rest } = $props();
|
|
32
34
|
// Generate unique IDs for ARIA associations (combobox pattern)
|
|
33
35
|
// Uses provided id prop or generates a random one using crypto API
|
|
34
|
-
const internal_id = $derived(id ?? `sms-${
|
|
36
|
+
const internal_id = $derived(id ?? `sms-${utils.get_uuid().slice(0, 8)}`);
|
|
35
37
|
const listbox_id = $derived(`${internal_id}-listbox`);
|
|
36
38
|
// Parse shortcut string into modifier+key parts
|
|
37
39
|
function parse_shortcut(shortcut) {
|
|
38
40
|
const parts = shortcut.toLowerCase().split(`+`).map((part) => part.trim());
|
|
39
41
|
const key = parts.pop() ?? ``;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
meta: parts.includes(`meta`) || parts.includes(`cmd`),
|
|
46
|
-
};
|
|
42
|
+
const ctrl = parts.includes(`ctrl`);
|
|
43
|
+
const shift = parts.includes(`shift`);
|
|
44
|
+
const alt = parts.includes(`alt`);
|
|
45
|
+
const meta = parts.includes(`meta`) || parts.includes(`cmd`);
|
|
46
|
+
return { key, ctrl, shift, alt, meta };
|
|
47
47
|
}
|
|
48
48
|
function matches_shortcut(event, shortcut) {
|
|
49
49
|
if (!shortcut)
|
|
@@ -57,19 +57,22 @@ function matches_shortcut(event, shortcut) {
|
|
|
57
57
|
const shift_matches = event.shiftKey === parsed.shift;
|
|
58
58
|
const alt_matches = event.altKey === parsed.alt;
|
|
59
59
|
const meta_matches = event.metaKey === parsed.meta;
|
|
60
|
-
return
|
|
60
|
+
return key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches;
|
|
61
61
|
}
|
|
62
|
+
// Platform detection for keyboard shortcuts (Mac uses Cmd, others use Ctrl)
|
|
63
|
+
const is_mac = typeof navigator !== `undefined` &&
|
|
64
|
+
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
65
|
+
const mod_key = is_mac ? `meta` : `ctrl`;
|
|
62
66
|
// Default shortcuts
|
|
63
67
|
const default_shortcuts = {
|
|
64
|
-
select_all:
|
|
65
|
-
clear_all:
|
|
68
|
+
select_all: `${mod_key}+a`,
|
|
69
|
+
clear_all: `${mod_key}+shift+a`,
|
|
66
70
|
open: null,
|
|
67
71
|
close: null,
|
|
72
|
+
undo: `${mod_key}+z`,
|
|
73
|
+
redo: `${mod_key}+shift+z`,
|
|
68
74
|
};
|
|
69
|
-
const effective_shortcuts = $derived({
|
|
70
|
-
...default_shortcuts,
|
|
71
|
-
...shortcuts,
|
|
72
|
-
});
|
|
75
|
+
const effective_shortcuts = $derived({ ...default_shortcuts, ...shortcuts });
|
|
73
76
|
// Extract loadOptions config into single derived object (supports both simple function and config object)
|
|
74
77
|
const load_options_config = $derived.by(() => {
|
|
75
78
|
if (!loadOptions)
|
|
@@ -94,8 +97,8 @@ function values_equal(val1, val2) {
|
|
|
94
97
|
if (empty1 && empty2)
|
|
95
98
|
return true;
|
|
96
99
|
if (Array.isArray(val1) && Array.isArray(val2)) {
|
|
97
|
-
return val1.length === val2.length &&
|
|
98
|
-
val1.every((item, idx) => item === val2[idx]);
|
|
100
|
+
return (val1.length === val2.length &&
|
|
101
|
+
val1.every((item, idx) => item === val2[idx]));
|
|
99
102
|
}
|
|
100
103
|
return false;
|
|
101
104
|
}
|
|
@@ -104,15 +107,17 @@ function values_equal(val1, val2) {
|
|
|
104
107
|
// infinite loops with reactive wrappers that clone arrays. See issue #309.
|
|
105
108
|
$effect.pre(() => {
|
|
106
109
|
const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
|
|
107
|
-
if (!values_equal(untrack(() => value), new_value))
|
|
110
|
+
if (!values_equal(untrack(() => value), new_value)) {
|
|
108
111
|
value = new_value;
|
|
112
|
+
}
|
|
109
113
|
});
|
|
110
114
|
$effect.pre(() => {
|
|
111
115
|
const new_selected = maxSelect === 1
|
|
112
116
|
? (value ? [value] : [])
|
|
113
117
|
: (Array.isArray(value) ? value : []);
|
|
114
|
-
if (!values_equal(untrack(() => selected), new_selected))
|
|
118
|
+
if (!values_equal(untrack(() => selected), new_selected)) {
|
|
115
119
|
selected = new_selected;
|
|
120
|
+
}
|
|
116
121
|
});
|
|
117
122
|
let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
|
|
118
123
|
let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
|
|
@@ -125,6 +130,83 @@ $effect(() => {
|
|
|
125
130
|
return () => clearTimeout(timer);
|
|
126
131
|
}
|
|
127
132
|
});
|
|
133
|
+
// History tracking for undo/redo
|
|
134
|
+
const max_history = $derived(history === true
|
|
135
|
+
? 50
|
|
136
|
+
: typeof history === `number` && Number.isFinite(history)
|
|
137
|
+
? Math.max(0, Math.floor(history))
|
|
138
|
+
: 0);
|
|
139
|
+
let history_stack = $state([]);
|
|
140
|
+
let history_index = $state(-1); // -1 = no history yet
|
|
141
|
+
let prev_selected = null; // null = uninitialized, sync on first run
|
|
142
|
+
// Track changes to selected via $effect (catches internal + external changes)
|
|
143
|
+
$effect(() => {
|
|
144
|
+
// Disabled when max_history is 0 (handles false, 0, negative, non-finite inputs)
|
|
145
|
+
const history_disabled = !(max_history > 0);
|
|
146
|
+
if (history_disabled) {
|
|
147
|
+
// Clear history when disabled so re-enabling starts fresh
|
|
148
|
+
history_stack = [];
|
|
149
|
+
history_index = -1;
|
|
150
|
+
// Don't read `selected` here to avoid creating unnecessary reactive dependency
|
|
151
|
+
prev_selected = null;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Initialize prev_selected on first run to avoid phantom undo from [] → initial selection
|
|
155
|
+
if (prev_selected === null) {
|
|
156
|
+
prev_selected = [...selected];
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Check if actually changed (avoid duplicates from reactive updates)
|
|
160
|
+
if (values_equal(selected, prev_selected))
|
|
161
|
+
return;
|
|
162
|
+
// On first change, push initial state first
|
|
163
|
+
if (history_stack.length === 0) {
|
|
164
|
+
history_stack = [[...prev_selected]];
|
|
165
|
+
history_index = 0;
|
|
166
|
+
}
|
|
167
|
+
// Truncate any redo states
|
|
168
|
+
if (history_index < history_stack.length - 1) {
|
|
169
|
+
history_stack = history_stack.slice(0, history_index + 1);
|
|
170
|
+
}
|
|
171
|
+
// Push new state
|
|
172
|
+
history_stack = [...history_stack, [...selected]];
|
|
173
|
+
history_index = history_stack.length - 1;
|
|
174
|
+
// Trim to max size (remove oldest)
|
|
175
|
+
if (history_stack.length > max_history) {
|
|
176
|
+
const excess = history_stack.length - max_history;
|
|
177
|
+
history_stack = history_stack.slice(excess);
|
|
178
|
+
history_index = Math.max(0, history_index - excess);
|
|
179
|
+
}
|
|
180
|
+
prev_selected = [...selected];
|
|
181
|
+
});
|
|
182
|
+
// Derived canUndo/canRedo (update bindable props reactively)
|
|
183
|
+
$effect(() => {
|
|
184
|
+
canUndo = max_history > 0 && !disabled && history_index > 0;
|
|
185
|
+
canRedo = max_history > 0 && !disabled && history_index < history_stack.length - 1;
|
|
186
|
+
});
|
|
187
|
+
// Undo: restore previous state
|
|
188
|
+
undo = () => {
|
|
189
|
+
if (max_history <= 0 || disabled || history_index <= 0)
|
|
190
|
+
return false;
|
|
191
|
+
const previous = [...selected];
|
|
192
|
+
history_index--;
|
|
193
|
+
selected = [...history_stack[history_index]];
|
|
194
|
+
prev_selected = [...selected]; // sync tracker to prevent $effect re-recording
|
|
195
|
+
onundo?.({ previous, current: selected });
|
|
196
|
+
return true;
|
|
197
|
+
};
|
|
198
|
+
// Redo: restore next state
|
|
199
|
+
redo = () => {
|
|
200
|
+
if (max_history <= 0 || disabled || history_index >= history_stack.length - 1) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const previous = [...selected];
|
|
204
|
+
history_index++;
|
|
205
|
+
selected = [...history_stack[history_index]];
|
|
206
|
+
prev_selected = [...selected]; // sync tracker to prevent $effect re-recording
|
|
207
|
+
onredo?.({ previous, current: selected });
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
128
210
|
// Debounced onsearch event - fires 150ms after search text stops changing
|
|
129
211
|
let search_debounce_timer = null;
|
|
130
212
|
let search_initialized = false;
|
|
@@ -158,13 +240,22 @@ let debounce_timer = null;
|
|
|
158
240
|
let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
|
|
159
241
|
// Cache selected keys and labels to avoid repeated .map() calls
|
|
160
242
|
let selected_keys = $derived(selected.map(key));
|
|
161
|
-
let selected_labels = $derived(selected.map(get_label));
|
|
243
|
+
let selected_labels = $derived(selected.map(utils.get_label));
|
|
162
244
|
// Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
|
|
163
245
|
let selected_keys_set = $derived(new Set(selected_keys));
|
|
164
|
-
|
|
246
|
+
// String-normalized for consistent comparison (numeric labels like 123 match "123")
|
|
247
|
+
let selected_labels_set = $derived(new Set(selected_labels.map((lbl) => `${lbl}`)));
|
|
248
|
+
// Lowercase labels set for case-insensitive duplicate detection
|
|
249
|
+
let selected_labels_lower_set = $derived(duplicates === `case-insensitive`
|
|
250
|
+
? new Set(selected_labels.map((lbl) => `${lbl}`.toLowerCase()))
|
|
251
|
+
: null);
|
|
252
|
+
// Helper to check if a label is already selected (respects case-insensitive mode)
|
|
253
|
+
const is_label_selected = (label) => selected_labels_lower_set
|
|
254
|
+
? selected_labels_lower_set.has(label.toLowerCase())
|
|
255
|
+
: selected_labels_set.has(label);
|
|
165
256
|
// Memoized Set of disabled option keys for O(1) lookups in large option sets
|
|
166
257
|
let disabled_option_keys = $derived(new Set(effective_options
|
|
167
|
-
.filter((opt) => is_object(opt) && opt.disabled)
|
|
258
|
+
.filter((opt) => utils.is_object(opt) && opt.disabled)
|
|
168
259
|
.map(key)));
|
|
169
260
|
// Check if an option is disabled (uses memoized Set for O(1) lookup)
|
|
170
261
|
const is_disabled = (opt) => disabled_option_keys.has(key(opt));
|
|
@@ -175,7 +266,7 @@ let grouped_options = $derived.by(() => {
|
|
|
175
266
|
const groups_map = new SvelteMap();
|
|
176
267
|
const ungrouped = [];
|
|
177
268
|
for (const opt of matchingOptions) {
|
|
178
|
-
if (has_group(opt)) {
|
|
269
|
+
if (utils.has_group(opt)) {
|
|
179
270
|
const existing = groups_map.get(opt.group);
|
|
180
271
|
if (existing)
|
|
181
272
|
existing.push(opt);
|
|
@@ -203,7 +294,11 @@ let grouped_options = $derived.by(() => {
|
|
|
203
294
|
}
|
|
204
295
|
if (ungrouped.length === 0)
|
|
205
296
|
return grouped;
|
|
206
|
-
const ungrouped_entry = {
|
|
297
|
+
const ungrouped_entry = {
|
|
298
|
+
group: null,
|
|
299
|
+
options: ungrouped,
|
|
300
|
+
collapsed: false,
|
|
301
|
+
};
|
|
207
302
|
return ungroupedPosition === `first`
|
|
208
303
|
? [ungrouped_entry, ...grouped]
|
|
209
304
|
: [...grouped, ungrouped_entry];
|
|
@@ -272,8 +367,9 @@ function expand_groups(groups_to_expand) {
|
|
|
272
367
|
if (groups_to_expand.length === 0)
|
|
273
368
|
return;
|
|
274
369
|
update_collapsed_groups(`delete`, groups_to_expand);
|
|
275
|
-
for (const group of groups_to_expand)
|
|
370
|
+
for (const group of groups_to_expand) {
|
|
276
371
|
ongroupToggle?.({ group, collapsed: false });
|
|
372
|
+
}
|
|
277
373
|
}
|
|
278
374
|
// Get names of collapsed groups that have matching options
|
|
279
375
|
const get_collapsed_with_matches = () => grouped_options.flatMap(({ group, collapsed, options: opts }) => group && collapsed && opts.length > 0 ? [group] : []);
|
|
@@ -284,12 +380,12 @@ $effect(() => {
|
|
|
284
380
|
}
|
|
285
381
|
});
|
|
286
382
|
// Normalize placeholder prop (supports string or { text, persistent } object)
|
|
287
|
-
const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
|
|
383
|
+
const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : (placeholder?.text ?? null));
|
|
288
384
|
const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
|
|
289
385
|
// Helper to sort selected options (used by add() and select_all())
|
|
290
386
|
function sort_selected(items) {
|
|
291
387
|
if (sortSelected === true) {
|
|
292
|
-
return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
|
|
388
|
+
return items.toSorted((op1, op2) => `${utils.get_label(op1)}`.localeCompare(`${utils.get_label(op2)}`));
|
|
293
389
|
}
|
|
294
390
|
else if (typeof sortSelected === `function`) {
|
|
295
391
|
return items.toSorted(sortSelected);
|
|
@@ -340,9 +436,9 @@ let window_width = $state(0);
|
|
|
340
436
|
const matches_search = (opt, search) => {
|
|
341
437
|
if (filterFunc(opt, search))
|
|
342
438
|
return true;
|
|
343
|
-
if (searchMatchesGroups && search && has_group(opt)) {
|
|
439
|
+
if (searchMatchesGroups && search && utils.has_group(opt)) {
|
|
344
440
|
return fuzzy
|
|
345
|
-
? fuzzy_match(search, opt.group)
|
|
441
|
+
? utils.fuzzy_match(search, opt.group)
|
|
346
442
|
: opt.group.toLowerCase().includes(search.toLowerCase());
|
|
347
443
|
}
|
|
348
444
|
return false;
|
|
@@ -391,21 +487,30 @@ function add(option_to_add, event) {
|
|
|
391
487
|
event.stopPropagation();
|
|
392
488
|
if (maxSelect !== null && selected.length >= maxSelect)
|
|
393
489
|
wiggle = true;
|
|
394
|
-
if (!isNaN(Number(option_to_add)) &&
|
|
490
|
+
if (!isNaN(Number(option_to_add)) &&
|
|
491
|
+
typeof selected_labels[0] === `number`) {
|
|
395
492
|
option_to_add = Number(option_to_add); // convert to number if possible
|
|
396
493
|
}
|
|
397
|
-
|
|
494
|
+
// Check for duplicates by key, plus label check for user-created options
|
|
495
|
+
// For duplicates=false (default), label check only applies to user-typed text
|
|
496
|
+
// For duplicates='case-insensitive', label check applies to all options
|
|
497
|
+
// Use key comparison instead of reference equality (more robust with Svelte proxies)
|
|
498
|
+
const option_key = key(option_to_add);
|
|
499
|
+
const is_from_options = effective_options.some((opt) => key(opt) === option_key);
|
|
500
|
+
const check_label = duplicates === `case-insensitive` || !is_from_options;
|
|
501
|
+
const is_duplicate = selected_keys_set.has(key(option_to_add)) ||
|
|
502
|
+
(check_label && is_label_selected(`${utils.get_label(option_to_add)}`));
|
|
398
503
|
const max_reached = maxSelect !== null && maxSelect !== 1 &&
|
|
399
504
|
selected.length >= maxSelect;
|
|
400
505
|
// Fire events for blocked add attempts
|
|
401
506
|
if (max_reached) {
|
|
402
507
|
onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
|
|
403
508
|
}
|
|
404
|
-
if (is_duplicate &&
|
|
509
|
+
if (is_duplicate && duplicates !== true)
|
|
405
510
|
onduplicate?.({ option: option_to_add });
|
|
406
511
|
if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
|
|
407
|
-
(duplicates || !is_duplicate)) {
|
|
408
|
-
if (!
|
|
512
|
+
(duplicates === true || !is_duplicate)) {
|
|
513
|
+
if (!is_from_options && // first check if we find option in the options list
|
|
409
514
|
// this has the side-effect of not allowing to user to add the same
|
|
410
515
|
// custom option twice in append mode
|
|
411
516
|
[true, `append`].includes(allowUserOptions) &&
|
|
@@ -451,7 +556,7 @@ function add(option_to_add, event) {
|
|
|
451
556
|
}
|
|
452
557
|
clear_validity();
|
|
453
558
|
handle_dropdown_after_select(event);
|
|
454
|
-
last_action = { type: `add`, label: `${get_label(option_to_add)}` };
|
|
559
|
+
last_action = { type: `add`, label: `${utils.get_label(option_to_add)}` };
|
|
455
560
|
onadd?.({ option: option_to_add });
|
|
456
561
|
onchange?.({ option: option_to_add, type: `add` });
|
|
457
562
|
}
|
|
@@ -476,7 +581,7 @@ function remove(option_to_drop, event) {
|
|
|
476
581
|
}
|
|
477
582
|
selected = selected.filter((_, remove_idx) => remove_idx !== idx);
|
|
478
583
|
clear_validity();
|
|
479
|
-
last_action = { type: `remove`, label: `${get_label(option_removed)}` };
|
|
584
|
+
last_action = { type: `remove`, label: `${utils.get_label(option_removed)}` };
|
|
480
585
|
onremove?.({ option: option_removed });
|
|
481
586
|
onchange?.({ option: option_removed, type: `remove` });
|
|
482
587
|
}
|
|
@@ -506,7 +611,8 @@ function handle_dropdown_after_select(event) {
|
|
|
506
611
|
const reached_max = selected.length >= (maxSelect ?? Infinity);
|
|
507
612
|
const should_close = closeDropdownOnSelect === true ||
|
|
508
613
|
closeDropdownOnSelect === `retain-focus` ||
|
|
509
|
-
(closeDropdownOnSelect === `if-mobile` &&
|
|
614
|
+
(closeDropdownOnSelect === `if-mobile` &&
|
|
615
|
+
window_width &&
|
|
510
616
|
window_width < breakpoint);
|
|
511
617
|
if (reached_max || should_close) {
|
|
512
618
|
close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
|
|
@@ -515,19 +621,24 @@ function handle_dropdown_after_select(event) {
|
|
|
515
621
|
input?.focus();
|
|
516
622
|
}
|
|
517
623
|
// Check if a user message (create option, duplicate warning, no match) is visible
|
|
518
|
-
const has_user_msg = $derived(searchText.length > 0 &&
|
|
519
|
-
(
|
|
520
|
-
|
|
624
|
+
const has_user_msg = $derived(searchText.length > 0 &&
|
|
625
|
+
Boolean((allowUserOptions && createOptionMsg) ||
|
|
626
|
+
(duplicates !== true && is_label_selected(searchText)) ||
|
|
627
|
+
(navigable_options.length === 0 && noMatchingOptionsMsg)));
|
|
521
628
|
// Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
|
|
522
629
|
async function handle_arrow_navigation(direction) {
|
|
523
630
|
ignore_hover = true;
|
|
524
631
|
// Auto-expand collapsed groups when keyboard navigating
|
|
525
|
-
if (keyboardExpandsCollapsedGroups &&
|
|
632
|
+
if (keyboardExpandsCollapsedGroups &&
|
|
633
|
+
collapsibleGroups &&
|
|
634
|
+
collapsedGroups.size > 0) {
|
|
526
635
|
expand_groups(get_collapsed_with_matches());
|
|
527
636
|
await tick();
|
|
528
637
|
}
|
|
529
638
|
// toggle user message when no options match but user can create
|
|
530
|
-
if (allowUserOptions &&
|
|
639
|
+
if (allowUserOptions &&
|
|
640
|
+
!navigable_options.length &&
|
|
641
|
+
searchText.length > 0) {
|
|
531
642
|
option_msg_is_active = !option_msg_is_active;
|
|
532
643
|
return;
|
|
533
644
|
}
|
|
@@ -550,10 +661,12 @@ async function handle_arrow_navigation(direction) {
|
|
|
550
661
|
option_msg_is_active = has_user_msg && activeIndex === navigable_options.length;
|
|
551
662
|
activeOption = option_msg_is_active
|
|
552
663
|
? null
|
|
553
|
-
: navigable_options[activeIndex] ?? null;
|
|
664
|
+
: (navigable_options[activeIndex] ?? null);
|
|
554
665
|
if (autoScroll) {
|
|
555
666
|
await tick();
|
|
556
|
-
document
|
|
667
|
+
document
|
|
668
|
+
.querySelector(`ul.options > li.active`)
|
|
669
|
+
?.scrollIntoViewIfNeeded?.();
|
|
557
670
|
}
|
|
558
671
|
// Fire onactivate for keyboard navigation only (not mouse hover)
|
|
559
672
|
onactivate?.({ option: activeOption, index: activeIndex });
|
|
@@ -587,6 +700,16 @@ async function handle_keydown(event) {
|
|
|
587
700
|
searchText = ``;
|
|
588
701
|
},
|
|
589
702
|
},
|
|
703
|
+
{
|
|
704
|
+
key: `undo`,
|
|
705
|
+
condition: () => !!canUndo,
|
|
706
|
+
action: () => undo?.(),
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
key: `redo`,
|
|
710
|
+
condition: () => !!canRedo,
|
|
711
|
+
action: () => redo?.(),
|
|
712
|
+
},
|
|
590
713
|
];
|
|
591
714
|
for (const { key, condition, action } of shortcut_actions) {
|
|
592
715
|
if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
|
|
@@ -659,7 +782,10 @@ function remove_all(event) {
|
|
|
659
782
|
// Only fire events if something was actually removed
|
|
660
783
|
if (removed_options.length > 0) {
|
|
661
784
|
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
662
|
-
last_action = {
|
|
785
|
+
last_action = {
|
|
786
|
+
type: `removeAll`,
|
|
787
|
+
label: `${removed_options.length} options`,
|
|
788
|
+
};
|
|
663
789
|
onremoveAll?.({ options: removed_options });
|
|
664
790
|
onchange?.({ options: selected, type: `removeAll` });
|
|
665
791
|
}
|
|
@@ -718,7 +844,7 @@ function toggle_group_selection(group_opts, group_collapsed, all_selected, event
|
|
|
718
844
|
}
|
|
719
845
|
}
|
|
720
846
|
// O(1) lookup using pre-computed Set instead of O(n) array.includes()
|
|
721
|
-
const is_selected = (label) => selected_labels_set.has(label);
|
|
847
|
+
const is_selected = (label) => selected_labels_set.has(`${label}`);
|
|
722
848
|
const if_enter_or_space = (handler) => (event) => {
|
|
723
849
|
if (event.key === `Enter` || event.code === `Space`) {
|
|
724
850
|
event.preventDefault();
|
|
@@ -742,8 +868,9 @@ function on_click_outside(event) {
|
|
|
742
868
|
if (outerDiv.contains(target))
|
|
743
869
|
return;
|
|
744
870
|
// 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))
|
|
871
|
+
if (portal_params?.active && ul_options && ul_options.contains(target)) {
|
|
746
872
|
return;
|
|
873
|
+
}
|
|
747
874
|
// Click is outside both the main component and any portalled dropdown
|
|
748
875
|
close_dropdown(event);
|
|
749
876
|
}
|
|
@@ -855,8 +982,9 @@ function portal(node, params) {
|
|
|
855
982
|
target_node = params.target_node;
|
|
856
983
|
render_in_place = typeof window === `undefined` ||
|
|
857
984
|
!document.body.contains(node);
|
|
858
|
-
if (open && !render_in_place && target_node)
|
|
985
|
+
if (open && !render_in_place && target_node) {
|
|
859
986
|
tick().then(update_position);
|
|
987
|
+
}
|
|
860
988
|
else if (!open || !target_node)
|
|
861
989
|
node.hidden = true;
|
|
862
990
|
},
|
|
@@ -871,7 +999,8 @@ function portal(node, params) {
|
|
|
871
999
|
}
|
|
872
1000
|
// Dynamic options loading - captures search at call time to avoid race conditions
|
|
873
1001
|
async function load_dynamic_options(reset) {
|
|
874
|
-
if (!load_options_config ||
|
|
1002
|
+
if (!load_options_config ||
|
|
1003
|
+
load_options_loading ||
|
|
875
1004
|
(!reset && !load_options_has_more)) {
|
|
876
1005
|
return;
|
|
877
1006
|
}
|
|
@@ -932,11 +1061,13 @@ $effect(() => {
|
|
|
932
1061
|
};
|
|
933
1062
|
});
|
|
934
1063
|
function handle_options_scroll(event) {
|
|
935
|
-
if (!load_options_config || load_options_loading || !load_options_has_more)
|
|
1064
|
+
if (!load_options_config || load_options_loading || !load_options_has_more) {
|
|
936
1065
|
return;
|
|
1066
|
+
}
|
|
937
1067
|
const { scrollTop, scrollHeight, clientHeight } = event.target;
|
|
938
|
-
if (scrollHeight - scrollTop - clientHeight <= 100)
|
|
1068
|
+
if (scrollHeight - scrollTop - clientHeight <= 100) {
|
|
939
1069
|
load_dynamic_options(false);
|
|
1070
|
+
}
|
|
940
1071
|
}
|
|
941
1072
|
</script>
|
|
942
1073
|
|
|
@@ -1000,11 +1131,9 @@ function handle_options_scroll(event) {
|
|
|
1000
1131
|
style={ulSelectedStyle}
|
|
1001
1132
|
>
|
|
1002
1133
|
{#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
|
|
1003
|
-
{@const selectedOptionStyle =
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
) ||
|
|
1007
|
-
null}
|
|
1134
|
+
{@const selectedOptionStyle = [utils.get_style(option, `selected`), liSelectedStyle]
|
|
1135
|
+
.filter(Boolean)
|
|
1136
|
+
.join(` `) || null}
|
|
1008
1137
|
<li
|
|
1009
1138
|
class={liSelectedClass}
|
|
1010
1139
|
role="option"
|
|
@@ -1022,26 +1151,20 @@ function handle_options_scroll(event) {
|
|
|
1022
1151
|
onmouseup={(event) => event.stopPropagation()}
|
|
1023
1152
|
>
|
|
1024
1153
|
{#if selectedItem}
|
|
1025
|
-
{@render selectedItem({
|
|
1026
|
-
option,
|
|
1027
|
-
idx,
|
|
1028
|
-
})}
|
|
1154
|
+
{@render selectedItem({ option, idx })}
|
|
1029
1155
|
{:else if children}
|
|
1030
|
-
{@render children({
|
|
1031
|
-
option,
|
|
1032
|
-
idx,
|
|
1033
|
-
})}
|
|
1156
|
+
{@render children({ option, idx })}
|
|
1034
1157
|
{:else if parseLabelsAsHtml}
|
|
1035
|
-
{@html get_label(option)}
|
|
1158
|
+
{@html utils.get_label(option)}
|
|
1036
1159
|
{:else}
|
|
1037
|
-
{get_label(option)}
|
|
1160
|
+
{utils.get_label(option)}
|
|
1038
1161
|
{/if}
|
|
1039
1162
|
{#if !disabled && can_remove}
|
|
1040
1163
|
<button
|
|
1041
1164
|
onclick={(event) => remove(option, event)}
|
|
1042
1165
|
onkeydown={if_enter_or_space((event) => remove(option, event))}
|
|
1043
1166
|
type="button"
|
|
1044
|
-
title="{removeBtnTitle} {get_label(option)}"
|
|
1167
|
+
title="{removeBtnTitle} {utils.get_label(option)}"
|
|
1045
1168
|
class="remove"
|
|
1046
1169
|
>
|
|
1047
1170
|
{#if removeIcon}
|
|
@@ -1188,8 +1311,9 @@ function handle_options_scroll(event) {
|
|
|
1188
1311
|
(group_name ?? `ungrouped-${group_idx}`)
|
|
1189
1312
|
}
|
|
1190
1313
|
{#if group_name !== null}
|
|
1191
|
-
{@const { all_selected, selected_count } = group_header_state.get(
|
|
1192
|
-
|
|
1314
|
+
{@const { all_selected, selected_count } = group_header_state.get(
|
|
1315
|
+
group_name,
|
|
1316
|
+
) ?? { all_selected: false, selected_count: 0 }}
|
|
1193
1317
|
{@const handle_toggle = () =>
|
|
1194
1318
|
collapsibleGroups && toggle_group_collapsed(group_name)}
|
|
1195
1319
|
{@const handle_group_select = (event: Event) =>
|
|
@@ -1253,14 +1377,12 @@ function handle_options_scroll(event) {
|
|
|
1253
1377
|
title = null,
|
|
1254
1378
|
selectedTitle = null,
|
|
1255
1379
|
disabledTitle = defaultDisabledTitle,
|
|
1256
|
-
} = is_object(option_item) ? option_item : { label: option_item }}
|
|
1380
|
+
} = utils.is_object(option_item) ? option_item : { label: option_item }}
|
|
1257
1381
|
{@const active = activeIndex === flat_idx && flat_idx >= 0}
|
|
1258
1382
|
{@const selected = is_selected(label)}
|
|
1259
|
-
{@const optionStyle =
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
) ||
|
|
1263
|
-
null}
|
|
1383
|
+
{@const optionStyle = [utils.get_style(option_item, `option`), liOptionStyle]
|
|
1384
|
+
.filter(Boolean)
|
|
1385
|
+
.join(` `) || null}
|
|
1264
1386
|
{#if is_option_visible(flat_idx)}
|
|
1265
1387
|
<li
|
|
1266
1388
|
id="{internal_id}-opt-{flat_idx}"
|
|
@@ -1290,24 +1412,18 @@ function handle_options_scroll(event) {
|
|
|
1290
1412
|
type="checkbox"
|
|
1291
1413
|
class="option-checkbox"
|
|
1292
1414
|
checked={selected}
|
|
1293
|
-
aria-label="Toggle {get_label(option_item)}"
|
|
1415
|
+
aria-label="Toggle {utils.get_label(option_item)}"
|
|
1294
1416
|
tabindex="-1"
|
|
1295
1417
|
/>
|
|
1296
1418
|
{/if}
|
|
1297
1419
|
{#if option}
|
|
1298
|
-
{@render option({
|
|
1299
|
-
option: option_item,
|
|
1300
|
-
idx: flat_idx,
|
|
1301
|
-
})}
|
|
1420
|
+
{@render option({ option: option_item, idx: flat_idx })}
|
|
1302
1421
|
{:else if children}
|
|
1303
|
-
{@render children({
|
|
1304
|
-
option: option_item,
|
|
1305
|
-
idx: flat_idx,
|
|
1306
|
-
})}
|
|
1422
|
+
{@render children({ option: option_item, idx: flat_idx })}
|
|
1307
1423
|
{:else if parseLabelsAsHtml}
|
|
1308
|
-
{@html get_label(option_item)}
|
|
1424
|
+
{@html utils.get_label(option_item)}
|
|
1309
1425
|
{:else}
|
|
1310
|
-
{get_label(option_item)}
|
|
1426
|
+
{utils.get_label(option_item)}
|
|
1311
1427
|
{/if}
|
|
1312
1428
|
</li>
|
|
1313
1429
|
{/if}
|
|
@@ -1315,8 +1431,7 @@ function handle_options_scroll(event) {
|
|
|
1315
1431
|
{/if}
|
|
1316
1432
|
{/each}
|
|
1317
1433
|
{#if searchText}
|
|
1318
|
-
{@const
|
|
1319
|
-
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
|
|
1434
|
+
{@const is_dupe = duplicates !== true && is_label_selected(searchText) && `dupe`}
|
|
1320
1435
|
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
|
|
1321
1436
|
{@const no_match =
|
|
1322
1437
|
Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
|
|
@@ -1366,7 +1481,11 @@ function handle_options_scroll(event) {
|
|
|
1366
1481
|
{/if}
|
|
1367
1482
|
{/if}
|
|
1368
1483
|
{#if loadOptions && load_options_loading}
|
|
1369
|
-
<li
|
|
1484
|
+
<li
|
|
1485
|
+
class="loading-more"
|
|
1486
|
+
role="status"
|
|
1487
|
+
aria-label="Loading more options"
|
|
1488
|
+
>
|
|
1370
1489
|
<CircleSpinner />
|
|
1371
1490
|
</li>
|
|
1372
1491
|
{/if}
|
|
@@ -1427,7 +1546,10 @@ function handle_options_scroll(event) {
|
|
|
1427
1546
|
z-index: var(--sms-open-z-index, 4);
|
|
1428
1547
|
}
|
|
1429
1548
|
:where(div.multiselect:focus-within) {
|
|
1430
|
-
border: var(
|
|
1549
|
+
border: var(
|
|
1550
|
+
--sms-focus-border,
|
|
1551
|
+
1pt solid var(--sms-active-color, cornflowerblue)
|
|
1552
|
+
);
|
|
1431
1553
|
}
|
|
1432
1554
|
:where(div.multiselect.disabled) {
|
|
1433
1555
|
background: var(--sms-disabled-bg, light-dark(lightgray, #444));
|
|
@@ -1462,7 +1584,10 @@ function handle_options_scroll(event) {
|
|
|
1462
1584
|
:where(div.multiselect > ul.selected > li.active) {
|
|
1463
1585
|
background: var(
|
|
1464
1586
|
--sms-li-active-bg,
|
|
1465
|
-
var(
|
|
1587
|
+
var(
|
|
1588
|
+
--sms-active-color,
|
|
1589
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1590
|
+
)
|
|
1466
1591
|
);
|
|
1467
1592
|
}
|
|
1468
1593
|
:is(div.multiselect button) {
|
|
@@ -1539,10 +1664,8 @@ function handle_options_scroll(event) {
|
|
|
1539
1664
|
width: 100%;
|
|
1540
1665
|
/* Default z-index if not portaled/overridden by portal */
|
|
1541
1666
|
z-index: var(--sms-options-z-index, 3);
|
|
1542
|
-
|
|
1543
1667
|
overflow: auto;
|
|
1544
|
-
transition: all
|
|
1545
|
-
0.2s; /* Consider if this transition is desirable with portal positioning */
|
|
1668
|
+
transition: all 0.2s; /* is this transition is desirable with portal positioning? */
|
|
1546
1669
|
box-sizing: border-box;
|
|
1547
1670
|
background: var(--sms-options-bg, light-dark(#fafafa, #1a1a1a));
|
|
1548
1671
|
max-height: var(--sms-options-max-height, 50vh);
|
|
@@ -1587,7 +1710,10 @@ function handle_options_scroll(event) {
|
|
|
1587
1710
|
:where(ul.options > li.active) {
|
|
1588
1711
|
background: var(
|
|
1589
1712
|
--sms-li-active-bg,
|
|
1590
|
-
var(
|
|
1713
|
+
var(
|
|
1714
|
+
--sms-active-color,
|
|
1715
|
+
light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15))
|
|
1716
|
+
)
|
|
1591
1717
|
);
|
|
1592
1718
|
}
|
|
1593
1719
|
:where(ul.options > li.disabled) {
|
|
@@ -1641,7 +1767,10 @@ function handle_options_scroll(event) {
|
|
|
1641
1767
|
}
|
|
1642
1768
|
:where(ul.options > li.group-header:not(:first-child)) {
|
|
1643
1769
|
margin-top: var(--sms-group-header-margin-top, 4pt);
|
|
1644
|
-
border-top: var(
|
|
1770
|
+
border-top: var(
|
|
1771
|
+
--sms-group-header-border-top,
|
|
1772
|
+
1px solid light-dark(#eee, #333)
|
|
1773
|
+
);
|
|
1645
1774
|
}
|
|
1646
1775
|
:where(ul.options > li.group-header.collapsible) {
|
|
1647
1776
|
cursor: pointer;
|