svelte-multiselect 11.2.4 → 11.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CircleSpinner.svelte.d.ts +3 -3
- package/dist/CmdPalette.svelte +8 -5
- package/dist/CmdPalette.svelte.d.ts +61 -16
- package/dist/CodeExample.svelte +12 -7
- package/dist/CodeExample.svelte.d.ts +6 -3
- package/dist/CopyButton.svelte +4 -3
- package/dist/CopyButton.svelte.d.ts +4 -4
- package/dist/FileDetails.svelte +3 -3
- package/dist/FileDetails.svelte.d.ts +6 -3
- package/dist/GitHubCorner.svelte +2 -2
- package/dist/GitHubCorner.svelte.d.ts +3 -3
- package/dist/Icon.svelte.d.ts +3 -3
- package/dist/MultiSelect.svelte +328 -153
- package/dist/MultiSelect.svelte.d.ts +9 -9
- package/dist/Nav.svelte +444 -0
- package/dist/Nav.svelte.d.ts +42 -0
- package/dist/PrevNext.svelte +3 -3
- package/dist/PrevNext.svelte.d.ts +51 -31
- package/dist/Toggle.svelte +2 -7
- package/dist/Toggle.svelte.d.ts +4 -9
- package/dist/Wiggle.svelte.d.ts +3 -3
- package/dist/attachments.d.ts +17 -9
- package/dist/attachments.js +80 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +33 -4
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +5 -3
- package/package.json +23 -16
- package/readme.md +84 -8
package/dist/MultiSelect.svelte
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
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';
|
|
2
3
|
import { flip } from 'svelte/animate';
|
|
3
4
|
import { highlight_matches } from './attachments';
|
|
4
5
|
import CircleSpinner from './CircleSpinner.svelte';
|
|
5
6
|
import Icon from './Icon.svelte';
|
|
6
|
-
import { fuzzy_match, get_label, get_style } from './utils';
|
|
7
|
+
import { fuzzy_match, get_label, get_style, is_object } from './utils';
|
|
7
8
|
import Wiggle from './Wiggle.svelte';
|
|
8
9
|
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) => {
|
|
9
10
|
if (!searchText)
|
|
@@ -12,81 +13,145 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
|
|
|
12
13
|
return fuzzy
|
|
13
14
|
? fuzzy_match(searchText, label)
|
|
14
15
|
: label.toLowerCase().includes(searchText.toLowerCase());
|
|
15
|
-
}, fuzzy = true, closeDropdownOnSelect =
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
}, 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
|
|
17
|
+
? (Array.isArray(value) ? value : [value])
|
|
18
|
+
: (options
|
|
19
|
+
?.filter((opt) => typeof opt === `object` && opt !== null && opt?.preselected)
|
|
20
|
+
.slice(0, maxSelect ?? undefined) ?? [])), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, onselectAll, portal: portal_params = {},
|
|
21
|
+
// Select all feature
|
|
22
|
+
selectAllOption = false, liSelectAllClass = ``,
|
|
23
|
+
// Dynamic options loading
|
|
24
|
+
loadOptions,
|
|
25
|
+
// Animation parameters for selected options flip animation
|
|
26
|
+
selectedFlipParams = { duration: 100 }, ...rest } = $props();
|
|
27
|
+
// Extract loadOptions function and config (supports both simple function and config object)
|
|
28
|
+
const load_options_fn = $derived(loadOptions
|
|
29
|
+
? (typeof loadOptions === `function` ? loadOptions : loadOptions.fetch)
|
|
30
|
+
: null);
|
|
31
|
+
const load_options_debounce_ms = $derived(loadOptions && typeof loadOptions === `object`
|
|
32
|
+
? (loadOptions.debounceMs ?? 300)
|
|
33
|
+
: 300);
|
|
34
|
+
const load_options_batch_size = $derived(loadOptions && typeof loadOptions === `object`
|
|
35
|
+
? (loadOptions.batchSize ?? 50)
|
|
36
|
+
: 50);
|
|
37
|
+
const load_options_on_open = $derived(loadOptions && typeof loadOptions === `object`
|
|
38
|
+
? (loadOptions.onOpen ?? true)
|
|
39
|
+
: true);
|
|
40
|
+
// Helper to compare arrays/values for equality to avoid unnecessary updates
|
|
41
|
+
// Prevents infinite loops when value/selected are bound to reactive wrappers
|
|
42
|
+
// that clone arrays on assignment (e.g. Superforms, Svelte stores). See issue #309.
|
|
43
|
+
function values_equal(val1, val2) {
|
|
44
|
+
if (val1 === val2)
|
|
45
|
+
return true;
|
|
46
|
+
if (Array.isArray(val1) && Array.isArray(val2)) {
|
|
47
|
+
return val1.length === val2.length &&
|
|
48
|
+
val1.every((item, idx) => item === val2[idx]);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// Sync selected ↔ value bidirectionally. Use untrack to prevent each effect from
|
|
53
|
+
// reacting to changes in the "destination" value, and values_equal to prevent
|
|
54
|
+
// infinite loops with reactive wrappers that clone arrays. See issue #309.
|
|
18
55
|
$effect.pre(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}); // sync selected updates to value
|
|
56
|
+
const new_value = maxSelect === 1 ? (selected[0] ?? null) : selected;
|
|
57
|
+
if (!values_equal(untrack(() => value), new_value))
|
|
58
|
+
value = new_value;
|
|
59
|
+
});
|
|
24
60
|
$effect.pre(() => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
const new_selected = maxSelect === 1
|
|
62
|
+
? (value ? [value] : [])
|
|
63
|
+
: (Array.isArray(value) ? value : []);
|
|
64
|
+
if (!values_equal(untrack(() => selected), new_selected))
|
|
65
|
+
selected = new_selected;
|
|
66
|
+
});
|
|
30
67
|
let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
|
|
31
|
-
|
|
68
|
+
let ignore_hover = $state(false); // ignore mouseover during keyboard navigation to prevent scroll-triggered hover
|
|
69
|
+
// Internal state for loadOptions feature (null = never loaded)
|
|
70
|
+
let loaded_options = $state([]);
|
|
71
|
+
let load_options_has_more = $state(true);
|
|
72
|
+
let load_options_loading = $state(false);
|
|
73
|
+
let load_options_last_search = $state(null);
|
|
74
|
+
let debounce_timer = null;
|
|
75
|
+
let effective_options = $derived(loadOptions ? loaded_options : (options ?? []));
|
|
76
|
+
// Cache selected keys and labels to avoid repeated .map() calls
|
|
77
|
+
let selected_keys = $derived(selected.map(key));
|
|
78
|
+
let selected_labels = $derived(selected.map(get_label));
|
|
79
|
+
// Normalize placeholder prop (supports string or { text, persistent } object)
|
|
80
|
+
const placeholder_text = $derived(typeof placeholder === `string` ? placeholder : placeholder?.text ?? null);
|
|
81
|
+
const placeholder_persistent = $derived(typeof placeholder === `object` && placeholder?.persistent === true);
|
|
82
|
+
// Helper to sort selected options (used by add() and select_all())
|
|
83
|
+
function sort_selected(items) {
|
|
84
|
+
if (sortSelected === true) {
|
|
85
|
+
return items.toSorted((op1, op2) => `${get_label(op1)}`.localeCompare(`${get_label(op2)}`));
|
|
86
|
+
}
|
|
87
|
+
else if (typeof sortSelected === `function`) {
|
|
88
|
+
return items.toSorted(sortSelected);
|
|
89
|
+
}
|
|
90
|
+
return items;
|
|
91
|
+
}
|
|
92
|
+
if (!loadOptions && !((options?.length ?? 0) > 0)) {
|
|
32
93
|
if (allowUserOptions || loading || disabled || allowEmpty) {
|
|
33
94
|
options = []; // initializing as array avoids errors when component mounts
|
|
34
95
|
}
|
|
35
96
|
else {
|
|
36
97
|
// error on empty options if user is not allowed to create custom options and loading is false
|
|
37
98
|
// and component is not disabled and allowEmpty is false
|
|
38
|
-
console.error(`MultiSelect received no options`);
|
|
99
|
+
console.error(`MultiSelect: received no options`);
|
|
39
100
|
}
|
|
40
101
|
}
|
|
41
102
|
if (maxSelect !== null && maxSelect < 1) {
|
|
42
|
-
console.error(`MultiSelect
|
|
103
|
+
console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
|
|
43
104
|
}
|
|
44
105
|
if (!Array.isArray(selected)) {
|
|
45
|
-
console.error(`MultiSelect
|
|
106
|
+
console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
|
|
46
107
|
}
|
|
47
108
|
if (maxSelect && typeof required === `number` && required > maxSelect) {
|
|
48
|
-
console.error(`MultiSelect maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
|
|
109
|
+
console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
|
|
49
110
|
}
|
|
50
111
|
if (parseLabelsAsHtml && allowUserOptions) {
|
|
51
|
-
console.warn(`
|
|
112
|
+
console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
|
|
52
113
|
}
|
|
53
114
|
if (sortSelected && selectedOptionsDraggable) {
|
|
54
|
-
console.warn(`MultiSelect
|
|
115
|
+
console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
|
|
55
116
|
`user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
|
|
56
117
|
}
|
|
57
118
|
if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
|
|
58
|
-
console.error(`MultiSelect
|
|
119
|
+
console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
|
|
59
120
|
`This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
|
|
60
121
|
}
|
|
61
122
|
if (maxOptions &&
|
|
62
123
|
(typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
|
|
63
|
-
console.error(`MultiSelect
|
|
124
|
+
console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
|
|
64
125
|
}
|
|
65
126
|
let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
|
|
66
127
|
let window_width = $state(0);
|
|
67
128
|
// options matching the current search text
|
|
68
129
|
$effect.pre(() => {
|
|
69
|
-
|
|
130
|
+
// When using loadOptions, server handles filtering, so skip client-side filterFunc
|
|
131
|
+
const opts_to_filter = effective_options;
|
|
132
|
+
matchingOptions = opts_to_filter.filter((opt) => (loadOptions || filterFunc(opt, searchText)) &&
|
|
70
133
|
// remove already selected options from dropdown list unless duplicate selections are allowed
|
|
71
134
|
// or keepSelectedInDropdown is enabled
|
|
72
|
-
(!
|
|
73
|
-
|
|
135
|
+
(!selected_keys.includes(key(opt)) || duplicates || keepSelectedInDropdown));
|
|
136
|
+
});
|
|
137
|
+
// reset activeIndex if out of bounds (can happen when options change while dropdown is open)
|
|
138
|
+
$effect(() => {
|
|
139
|
+
if (activeIndex !== null && !matchingOptions[activeIndex]) {
|
|
140
|
+
activeIndex = null;
|
|
141
|
+
}
|
|
74
142
|
});
|
|
75
|
-
// raise if matchingOptions[activeIndex] does not yield a value
|
|
76
|
-
if (activeIndex !== null && !matchingOptions[activeIndex]) {
|
|
77
|
-
throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
|
|
78
|
-
}
|
|
79
143
|
// update activeOption when activeIndex changes
|
|
80
144
|
$effect(() => {
|
|
81
145
|
activeOption = matchingOptions[activeIndex ?? -1] ?? null;
|
|
82
146
|
});
|
|
147
|
+
// Helper to check if removing an option would violate minSelect constraint
|
|
148
|
+
const can_remove = $derived(minSelect === null || selected.length > minSelect);
|
|
83
149
|
// toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
|
|
84
150
|
function toggle_option(option_to_toggle, event) {
|
|
85
|
-
const is_currently_selected =
|
|
151
|
+
const is_currently_selected = selected_keys.includes(key(option_to_toggle));
|
|
86
152
|
if (is_currently_selected) {
|
|
87
|
-
if (
|
|
153
|
+
if (can_remove)
|
|
88
154
|
remove(option_to_toggle, event);
|
|
89
|
-
}
|
|
90
155
|
}
|
|
91
156
|
else
|
|
92
157
|
add(option_to_toggle, event);
|
|
@@ -96,25 +161,25 @@ function add(option_to_add, event) {
|
|
|
96
161
|
event.stopPropagation();
|
|
97
162
|
if (maxSelect !== null && selected.length >= maxSelect)
|
|
98
163
|
wiggle = true;
|
|
99
|
-
if (!isNaN(Number(option_to_add)) && typeof
|
|
164
|
+
if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
|
|
100
165
|
option_to_add = Number(option_to_add); // convert to number if possible
|
|
101
166
|
}
|
|
102
|
-
const is_duplicate =
|
|
167
|
+
const is_duplicate = selected_keys.includes(key(option_to_add));
|
|
103
168
|
if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
|
|
104
169
|
(duplicates || !is_duplicate)) {
|
|
105
|
-
if (!
|
|
170
|
+
if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
|
|
106
171
|
// this has the side-effect of not allowing to user to add the same
|
|
107
172
|
// custom option twice in append mode
|
|
108
173
|
[true, `append`].includes(allowUserOptions) &&
|
|
109
174
|
searchText.length > 0) {
|
|
110
175
|
// user entered text but no options match, so if allowUserOptions = true | 'append', we create
|
|
111
176
|
// a new option from the user-entered text
|
|
112
|
-
if (typeof
|
|
177
|
+
if (typeof effective_options[0] === `object`) {
|
|
113
178
|
// if 1st option is an object, we create new option as object to keep type homogeneity
|
|
114
179
|
option_to_add = { label: searchText };
|
|
115
180
|
}
|
|
116
181
|
else {
|
|
117
|
-
if ([`number`, `undefined`].includes(typeof
|
|
182
|
+
if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
|
|
118
183
|
!isNaN(Number(searchText))) {
|
|
119
184
|
// create new option as number if it parses to a number and 1st option is also number or missing
|
|
120
185
|
option_to_add = Number(searchText);
|
|
@@ -125,46 +190,31 @@ function add(option_to_add, event) {
|
|
|
125
190
|
}
|
|
126
191
|
// Fire oncreate event for all user-created options, regardless of type
|
|
127
192
|
oncreate?.({ option: option_to_add });
|
|
128
|
-
if (allowUserOptions === `append`)
|
|
129
|
-
|
|
193
|
+
if (allowUserOptions === `append`) {
|
|
194
|
+
if (loadOptions) {
|
|
195
|
+
loaded_options = [...loaded_options, option_to_add];
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
options = [...(options ?? []), option_to_add];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
130
201
|
}
|
|
131
202
|
if (resetFilterOnAdd)
|
|
132
203
|
searchText = ``; // reset search string on selection
|
|
133
204
|
if ([``, undefined, null].includes(option_to_add)) {
|
|
134
|
-
console.error(`MultiSelect: encountered falsy option
|
|
205
|
+
console.error(`MultiSelect: encountered falsy option`, option_to_add);
|
|
135
206
|
return;
|
|
136
207
|
}
|
|
137
208
|
// for maxSelect = 1 we always replace current option with new one
|
|
138
209
|
if (maxSelect === 1)
|
|
139
210
|
selected = [option_to_add];
|
|
140
211
|
else {
|
|
141
|
-
selected = [...selected, option_to_add];
|
|
142
|
-
if (sortSelected === true) {
|
|
143
|
-
selected = selected.sort((op1, op2) => {
|
|
144
|
-
const [label1, label2] = [get_label(op1), get_label(op2)];
|
|
145
|
-
// coerce to string if labels are numbers
|
|
146
|
-
return `${label1}`.localeCompare(`${label2}`);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
else if (typeof sortSelected === `function`) {
|
|
150
|
-
selected = selected.sort(sortSelected);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
const reached_max_select = selected.length >= (maxSelect ?? Infinity);
|
|
154
|
-
const dropdown_should_close = closeDropdownOnSelect === true ||
|
|
155
|
-
closeDropdownOnSelect === `retain-focus` ||
|
|
156
|
-
(closeDropdownOnSelect === `if-mobile` && window_width &&
|
|
157
|
-
window_width < breakpoint);
|
|
158
|
-
const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
|
|
159
|
-
if (reached_max_select || dropdown_should_close) {
|
|
160
|
-
close_dropdown(event, should_retain_focus);
|
|
212
|
+
selected = sort_selected([...selected, option_to_add]);
|
|
161
213
|
}
|
|
162
|
-
|
|
163
|
-
|
|
214
|
+
clear_validity();
|
|
215
|
+
handle_dropdown_after_select(event);
|
|
164
216
|
onadd?.({ option: option_to_add });
|
|
165
217
|
onchange?.({ option: option_to_add, type: `add` });
|
|
166
|
-
invalid = false; // reset error status whenever new items are selected
|
|
167
|
-
form_input?.setCustomValidity(``);
|
|
168
218
|
}
|
|
169
219
|
}
|
|
170
220
|
// remove an option from selected list
|
|
@@ -178,15 +228,15 @@ function remove(option_to_drop, event) {
|
|
|
178
228
|
// if option with label could not be found but allowUserOptions is truthy,
|
|
179
229
|
// assume it was created by user and create corresponding option object
|
|
180
230
|
// on the fly for use as event payload
|
|
181
|
-
const
|
|
182
|
-
option_removed = (
|
|
231
|
+
const is_object_option = typeof effective_options[0] === `object`;
|
|
232
|
+
option_removed = (is_object_option ? { label: option_to_drop } : option_to_drop);
|
|
183
233
|
}
|
|
184
234
|
if (option_removed === undefined) {
|
|
185
|
-
|
|
235
|
+
console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
|
|
236
|
+
return;
|
|
186
237
|
}
|
|
187
238
|
selected = [...selected]; // trigger Svelte rerender
|
|
188
|
-
|
|
189
|
-
form_input?.setCustomValidity(``);
|
|
239
|
+
clear_validity();
|
|
190
240
|
onremove?.({ option: option_removed });
|
|
191
241
|
onchange?.({ option: option_removed, type: `remove` });
|
|
192
242
|
}
|
|
@@ -208,6 +258,52 @@ function close_dropdown(event, retain_focus = false) {
|
|
|
208
258
|
activeIndex = null;
|
|
209
259
|
onclose?.({ event });
|
|
210
260
|
}
|
|
261
|
+
function clear_validity() {
|
|
262
|
+
invalid = false;
|
|
263
|
+
form_input?.setCustomValidity(``);
|
|
264
|
+
}
|
|
265
|
+
function handle_dropdown_after_select(event) {
|
|
266
|
+
const reached_max = selected.length >= (maxSelect ?? Infinity);
|
|
267
|
+
const should_close = closeDropdownOnSelect === true ||
|
|
268
|
+
closeDropdownOnSelect === `retain-focus` ||
|
|
269
|
+
(closeDropdownOnSelect === `if-mobile` && window_width &&
|
|
270
|
+
window_width < breakpoint);
|
|
271
|
+
if (reached_max || should_close) {
|
|
272
|
+
close_dropdown(event, closeDropdownOnSelect === `retain-focus`);
|
|
273
|
+
}
|
|
274
|
+
else
|
|
275
|
+
input?.focus();
|
|
276
|
+
}
|
|
277
|
+
// Check if a user message (create option, duplicate warning, no match) is visible
|
|
278
|
+
const has_user_msg = $derived(searchText.length > 0 && Boolean((allowUserOptions && createOptionMsg) ||
|
|
279
|
+
(!duplicates && selected_labels.includes(searchText)) ||
|
|
280
|
+
(matchingOptions.length === 0 && noMatchingOptionsMsg)));
|
|
281
|
+
// Handle arrow key navigation through options (uses module-scope `has_user_msg`)
|
|
282
|
+
async function handle_arrow_navigation(direction) {
|
|
283
|
+
ignore_hover = true;
|
|
284
|
+
// toggle user message when no options match but user can create
|
|
285
|
+
if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
|
|
286
|
+
option_msg_is_active = !option_msg_is_active;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (activeIndex === null && !matchingOptions.length)
|
|
290
|
+
return; // nothing to navigate
|
|
291
|
+
// activate first option or navigate with wrap-around
|
|
292
|
+
if (activeIndex === null) {
|
|
293
|
+
activeIndex = 0;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const total = matchingOptions.length + (has_user_msg ? 1 : 0);
|
|
297
|
+
activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
|
|
298
|
+
}
|
|
299
|
+
// update active state based on new index
|
|
300
|
+
option_msg_is_active = has_user_msg && activeIndex === matchingOptions.length;
|
|
301
|
+
activeOption = option_msg_is_active ? null : matchingOptions[activeIndex] ?? null;
|
|
302
|
+
if (autoScroll) {
|
|
303
|
+
await tick();
|
|
304
|
+
document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
211
307
|
// handle all keyboard events this component receives
|
|
212
308
|
async function handle_keydown(event) {
|
|
213
309
|
// on escape or tab out of input: close options dropdown and reset search text
|
|
@@ -215,20 +311,17 @@ async function handle_keydown(event) {
|
|
|
215
311
|
event.stopPropagation();
|
|
216
312
|
close_dropdown(event);
|
|
217
313
|
searchText = ``;
|
|
218
|
-
} // on enter key: toggle active option
|
|
314
|
+
} // on enter key: toggle active option
|
|
219
315
|
else if (event.key === `Enter`) {
|
|
220
316
|
event.stopPropagation();
|
|
221
317
|
event.preventDefault(); // prevent enter key from triggering form submission
|
|
222
318
|
if (activeOption) {
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
if (minSelect === null || selected.length > minSelect) {
|
|
319
|
+
if (selected_keys.includes(key(activeOption))) {
|
|
320
|
+
if (can_remove)
|
|
226
321
|
remove(activeOption, event);
|
|
227
|
-
}
|
|
228
322
|
}
|
|
229
323
|
else
|
|
230
|
-
add(activeOption, event);
|
|
231
|
-
searchText = ``;
|
|
324
|
+
add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
|
|
232
325
|
}
|
|
233
326
|
else if (allowUserOptions && searchText.length > 0) {
|
|
234
327
|
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
|
|
@@ -240,59 +333,14 @@ async function handle_keydown(event) {
|
|
|
240
333
|
open_dropdown(event);
|
|
241
334
|
}
|
|
242
335
|
} // on up/down arrow keys: update active option
|
|
243
|
-
else if (
|
|
336
|
+
else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
|
|
244
337
|
event.stopPropagation();
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
248
|
-
activeIndex = 0;
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
|
|
252
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
253
|
-
// if allowUserOptions is truthy and user entered text but no options match, we make
|
|
254
|
-
// <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
|
|
255
|
-
option_msg_is_active = !option_msg_is_active;
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
else if (activeIndex === null) {
|
|
259
|
-
// if no option is active and no options are matching, do nothing
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
263
|
-
// if none of the above special cases apply, we make next/prev option
|
|
264
|
-
// active with wrap around at both ends
|
|
265
|
-
const increment = event.key === `ArrowUp` ? -1 : 1;
|
|
266
|
-
// Include user message in total count if it exists
|
|
267
|
-
const has_user_msg = searchText && ((allowUserOptions && createOptionMsg) ||
|
|
268
|
-
(!duplicates && selected.map(get_label).includes(searchText)) ||
|
|
269
|
-
(matchingOptions.length === 0 && noMatchingOptionsMsg));
|
|
270
|
-
const total_items = matchingOptions.length + (has_user_msg ? 1 : 0);
|
|
271
|
-
activeIndex = (activeIndex + increment) % total_items;
|
|
272
|
-
// in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
|
|
273
|
-
// need to do manual wrap around at 0
|
|
274
|
-
if (activeIndex < 0)
|
|
275
|
-
activeIndex = total_items - 1;
|
|
276
|
-
// Handle user message activation
|
|
277
|
-
if (has_user_msg && activeIndex === matchingOptions.length) {
|
|
278
|
-
option_msg_is_active = true;
|
|
279
|
-
activeOption = null;
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
option_msg_is_active = false;
|
|
283
|
-
activeOption = matchingOptions[activeIndex] ?? null;
|
|
284
|
-
}
|
|
285
|
-
if (autoScroll) {
|
|
286
|
-
await tick();
|
|
287
|
-
const li = document.querySelector(`ul.options > li.active`);
|
|
288
|
-
if (li)
|
|
289
|
-
li.scrollIntoViewIfNeeded?.();
|
|
290
|
-
}
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
|
|
291
340
|
} // on backspace key: remove last selected option
|
|
292
341
|
else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
|
|
293
342
|
event.stopPropagation();
|
|
294
|
-
|
|
295
|
-
if (minSelect === null || selected.length > minSelect) {
|
|
343
|
+
if (can_remove) {
|
|
296
344
|
const last_option = selected.at(-1);
|
|
297
345
|
if (last_option)
|
|
298
346
|
remove(last_option, event);
|
|
@@ -312,21 +360,41 @@ function remove_all(event) {
|
|
|
312
360
|
// If no minSelect constraint, remove all
|
|
313
361
|
removed_options = selected;
|
|
314
362
|
selected = [];
|
|
315
|
-
searchText = ``;
|
|
363
|
+
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
316
364
|
}
|
|
317
365
|
else if (selected.length > minSelect) {
|
|
318
366
|
// Keep the first minSelect items
|
|
319
367
|
removed_options = selected.slice(minSelect);
|
|
320
368
|
selected = selected.slice(0, minSelect);
|
|
321
|
-
searchText = ``;
|
|
369
|
+
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
370
|
+
}
|
|
371
|
+
// Only fire events if something was actually removed
|
|
372
|
+
if (removed_options.length > 0) {
|
|
373
|
+
onremoveAll?.({ options: removed_options });
|
|
374
|
+
onchange?.({ options: selected, type: `removeAll` });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function select_all(event) {
|
|
378
|
+
event.stopPropagation();
|
|
379
|
+
const limit = maxSelect ?? Infinity;
|
|
380
|
+
// Use matchingOptions for "select all visible" semantics
|
|
381
|
+
const options_to_add = matchingOptions.filter((opt) => {
|
|
382
|
+
const is_disabled = is_object(opt) && opt.disabled;
|
|
383
|
+
return !is_disabled && !selected_keys.includes(key(opt));
|
|
384
|
+
}).slice(0, limit - selected.length);
|
|
385
|
+
if (options_to_add.length > 0) {
|
|
386
|
+
selected = sort_selected([...selected, ...options_to_add]);
|
|
387
|
+
if (resetFilterOnAdd)
|
|
388
|
+
searchText = ``;
|
|
389
|
+
clear_validity();
|
|
390
|
+
handle_dropdown_after_select(event);
|
|
391
|
+
onselectAll?.({ options: options_to_add });
|
|
392
|
+
onchange?.({ options: selected, type: `selectAll` });
|
|
322
393
|
}
|
|
323
|
-
onremoveAll?.({ options: removed_options });
|
|
324
|
-
onchange?.({ options: selected, type: `removeAll` });
|
|
325
|
-
// If selected.length <= minSelect, do nothing (can't remove any more)
|
|
326
394
|
}
|
|
327
|
-
let is_selected = $derived((label) =>
|
|
395
|
+
let is_selected = $derived((label) => selected_labels.includes(label));
|
|
328
396
|
const if_enter_or_space = (handler) => (event) => {
|
|
329
|
-
if (
|
|
397
|
+
if (event.key === `Enter` || event.code === `Space`) {
|
|
330
398
|
event.preventDefault();
|
|
331
399
|
handler(event);
|
|
332
400
|
}
|
|
@@ -464,6 +532,74 @@ function portal(node, params) {
|
|
|
464
532
|
};
|
|
465
533
|
}
|
|
466
534
|
}
|
|
535
|
+
// Dynamic options loading - captures search at call time to avoid race conditions
|
|
536
|
+
async function load_dynamic_options(reset) {
|
|
537
|
+
if (!load_options_fn || load_options_loading || (!reset && !load_options_has_more)) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Capture search term at call time to avoid race with user typing during fetch
|
|
541
|
+
const search = searchText;
|
|
542
|
+
const offset = reset ? 0 : loaded_options.length;
|
|
543
|
+
load_options_loading = true;
|
|
544
|
+
try {
|
|
545
|
+
const limit = load_options_batch_size;
|
|
546
|
+
const result = await load_options_fn({ search, offset, limit });
|
|
547
|
+
loaded_options = reset ? result.options : [...loaded_options, ...result.options];
|
|
548
|
+
load_options_has_more = result.hasMore;
|
|
549
|
+
load_options_last_search = search;
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
console.error(`MultiSelect: loadOptions error:`, err);
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
load_options_loading = false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// Single effect handles initial load + search changes
|
|
559
|
+
$effect(() => {
|
|
560
|
+
if (!load_options_fn)
|
|
561
|
+
return;
|
|
562
|
+
// Reset state when dropdown closes so next open triggers fresh load
|
|
563
|
+
if (!open) {
|
|
564
|
+
load_options_last_search = null;
|
|
565
|
+
loaded_options = [];
|
|
566
|
+
load_options_has_more = true;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (debounce_timer)
|
|
570
|
+
clearTimeout(debounce_timer);
|
|
571
|
+
const search = searchText;
|
|
572
|
+
const is_first_load = load_options_last_search === null;
|
|
573
|
+
if (is_first_load) {
|
|
574
|
+
if (load_options_on_open) {
|
|
575
|
+
// Load immediately on dropdown open
|
|
576
|
+
load_dynamic_options(true);
|
|
577
|
+
}
|
|
578
|
+
else if (search) {
|
|
579
|
+
// onOpen=false but user typed - debounce and load
|
|
580
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
|
|
581
|
+
}
|
|
582
|
+
// If onOpen=false and no search text, do nothing (wait for user to type)
|
|
583
|
+
}
|
|
584
|
+
else if (search !== load_options_last_search) {
|
|
585
|
+
// Subsequent loads: debounce search changes
|
|
586
|
+
// Clear stale results immediately so UI doesn't show wrong results while loading
|
|
587
|
+
loaded_options = [];
|
|
588
|
+
load_options_has_more = true;
|
|
589
|
+
debounce_timer = setTimeout(() => load_dynamic_options(true), load_options_debounce_ms);
|
|
590
|
+
}
|
|
591
|
+
return () => {
|
|
592
|
+
if (debounce_timer)
|
|
593
|
+
clearTimeout(debounce_timer);
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
function handle_options_scroll(event) {
|
|
597
|
+
if (!load_options_fn || load_options_loading || !load_options_has_more)
|
|
598
|
+
return;
|
|
599
|
+
const { scrollTop, scrollHeight, clientHeight } = event.target;
|
|
600
|
+
if (scrollHeight - scrollTop - clientHeight <= 100)
|
|
601
|
+
load_dynamic_options(false);
|
|
602
|
+
}
|
|
467
603
|
</script>
|
|
468
604
|
|
|
469
605
|
<svelte:window
|
|
@@ -523,7 +659,7 @@ function portal(node, params) {
|
|
|
523
659
|
aria-label="selected options"
|
|
524
660
|
style={ulSelectedStyle}
|
|
525
661
|
>
|
|
526
|
-
{#each selected as option, idx (duplicates ?
|
|
662
|
+
{#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
|
|
527
663
|
{@const selectedOptionStyle =
|
|
528
664
|
[get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
|
|
529
665
|
` `,
|
|
@@ -533,7 +669,7 @@ function portal(node, params) {
|
|
|
533
669
|
class={liSelectedClass}
|
|
534
670
|
role="option"
|
|
535
671
|
aria-selected="true"
|
|
536
|
-
animate:flip={
|
|
672
|
+
animate:flip={selectedFlipParams}
|
|
537
673
|
draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
|
|
538
674
|
ondragstart={dragstart(idx)}
|
|
539
675
|
ondragover={(event) => {
|
|
@@ -543,6 +679,7 @@ function portal(node, params) {
|
|
|
543
679
|
ondragenter={() => (drag_idx = idx)}
|
|
544
680
|
class:active={drag_idx === idx}
|
|
545
681
|
style={selectedOptionStyle}
|
|
682
|
+
onmouseup={(event) => event.stopPropagation()}
|
|
546
683
|
>
|
|
547
684
|
{#if selectedItem}
|
|
548
685
|
{@render selectedItem({
|
|
@@ -559,7 +696,7 @@ function portal(node, params) {
|
|
|
559
696
|
{:else}
|
|
560
697
|
{get_label(option)}
|
|
561
698
|
{/if}
|
|
562
|
-
{#if !disabled &&
|
|
699
|
+
{#if !disabled && can_remove}
|
|
563
700
|
<button
|
|
564
701
|
onclick={(event) => remove(option, event)}
|
|
565
702
|
onkeydown={if_enter_or_space((event) => remove(option, event))}
|
|
@@ -586,7 +723,7 @@ function portal(node, params) {
|
|
|
586
723
|
{autocomplete}
|
|
587
724
|
{inputmode}
|
|
588
725
|
{pattern}
|
|
589
|
-
placeholder={selected.length === 0 ?
|
|
726
|
+
placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
|
|
590
727
|
aria-invalid={invalid ? `true` : null}
|
|
591
728
|
ondrop={() => false}
|
|
592
729
|
onmouseup={open_dropdown}
|
|
@@ -609,7 +746,7 @@ function portal(node, params) {
|
|
|
609
746
|
disabled,
|
|
610
747
|
invalid,
|
|
611
748
|
id,
|
|
612
|
-
placeholder,
|
|
749
|
+
placeholder: placeholder_text,
|
|
613
750
|
open,
|
|
614
751
|
required,
|
|
615
752
|
})}
|
|
@@ -658,7 +795,8 @@ function portal(node, params) {
|
|
|
658
795
|
{/if}
|
|
659
796
|
|
|
660
797
|
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
|
|
661
|
-
{#if (searchText && noMatchingOptionsMsg) ||
|
|
798
|
+
{#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
|
|
799
|
+
loadOptions}
|
|
662
800
|
<ul
|
|
663
801
|
use:portal={{ target_node: outerDiv, ...portal_params }}
|
|
664
802
|
{@attach highlight_matches({
|
|
@@ -680,14 +818,30 @@ function portal(node, params) {
|
|
|
680
818
|
aria-disabled={disabled ? `true` : null}
|
|
681
819
|
bind:this={ul_options}
|
|
682
820
|
style={ulOptionsStyle}
|
|
821
|
+
onscroll={handle_options_scroll}
|
|
822
|
+
onmousemove={() => (ignore_hover = false)}
|
|
683
823
|
>
|
|
824
|
+
{#if selectAllOption && effective_options.length > 0 &&
|
|
825
|
+
(maxSelect === null || maxSelect > 1)}
|
|
826
|
+
{@const label = typeof selectAllOption === `string` ? selectAllOption : `Select all`}
|
|
827
|
+
<li
|
|
828
|
+
class="select-all {liSelectAllClass}"
|
|
829
|
+
onclick={select_all}
|
|
830
|
+
onkeydown={if_enter_or_space(select_all)}
|
|
831
|
+
role="option"
|
|
832
|
+
aria-selected="false"
|
|
833
|
+
tabindex="0"
|
|
834
|
+
>
|
|
835
|
+
{label}
|
|
836
|
+
</li>
|
|
837
|
+
{/if}
|
|
684
838
|
{#each matchingOptions.slice(
|
|
685
839
|
0,
|
|
686
840
|
maxOptions == null ? Infinity : Math.max(0, maxOptions),
|
|
687
841
|
) as
|
|
688
842
|
option_item,
|
|
689
843
|
idx
|
|
690
|
-
(duplicates ?
|
|
844
|
+
(duplicates ? `${key(option_item)}-${idx}` : key(option_item))
|
|
691
845
|
}
|
|
692
846
|
{@const {
|
|
693
847
|
label,
|
|
@@ -695,7 +849,7 @@ function portal(node, params) {
|
|
|
695
849
|
title = null,
|
|
696
850
|
selectedTitle = null,
|
|
697
851
|
disabledTitle = defaultDisabledTitle,
|
|
698
|
-
} = option_item
|
|
852
|
+
} = is_object(option_item) ? option_item : { label: option_item }}
|
|
699
853
|
{@const active = activeIndex === idx}
|
|
700
854
|
{@const selected = is_selected(label)}
|
|
701
855
|
{@const optionStyle =
|
|
@@ -715,7 +869,7 @@ function portal(node, params) {
|
|
|
715
869
|
class:disabled
|
|
716
870
|
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
717
871
|
onmouseover={() => {
|
|
718
|
-
if (!disabled) activeIndex = idx
|
|
872
|
+
if (!disabled && !ignore_hover) activeIndex = idx
|
|
719
873
|
}}
|
|
720
874
|
onfocus={() => {
|
|
721
875
|
if (!disabled) activeIndex = idx
|
|
@@ -758,18 +912,18 @@ function portal(node, params) {
|
|
|
758
912
|
</li>
|
|
759
913
|
{/each}
|
|
760
914
|
{#if searchText}
|
|
761
|
-
{@const text_input_is_duplicate =
|
|
915
|
+
{@const text_input_is_duplicate = selected_labels.includes(searchText)}
|
|
762
916
|
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
|
|
763
917
|
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
|
|
764
918
|
{@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
|
|
765
919
|
`no-match`}
|
|
766
920
|
{@const msgType = is_dupe || can_create || no_match}
|
|
767
|
-
{
|
|
768
|
-
{@const msg = {
|
|
921
|
+
{@const msg = msgType && {
|
|
769
922
|
dupe: duplicateOptionMsg,
|
|
770
923
|
create: createOptionMsg,
|
|
771
924
|
'no-match': noMatchingOptionsMsg,
|
|
772
925
|
}[msgType]}
|
|
926
|
+
{#if msg}
|
|
773
927
|
<li
|
|
774
928
|
onclick={(event) => {
|
|
775
929
|
if (msgType === `create` && allowUserOptions) {
|
|
@@ -792,7 +946,7 @@ function portal(node, params) {
|
|
|
792
946
|
? duplicateOptionMsg
|
|
793
947
|
: ``}
|
|
794
948
|
class:active={option_msg_is_active}
|
|
795
|
-
onmouseover={() => (option_msg_is_active = true)}
|
|
949
|
+
onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
|
|
796
950
|
onfocus={() => (option_msg_is_active = true)}
|
|
797
951
|
onmouseout={() => (option_msg_is_active = false)}
|
|
798
952
|
onblur={() => (option_msg_is_active = false)}
|
|
@@ -817,6 +971,11 @@ function portal(node, params) {
|
|
|
817
971
|
</li>
|
|
818
972
|
{/if}
|
|
819
973
|
{/if}
|
|
974
|
+
{#if loadOptions && load_options_loading}
|
|
975
|
+
<li class="loading-more" role="status" aria-label="Loading more options">
|
|
976
|
+
<CircleSpinner />
|
|
977
|
+
</li>
|
|
978
|
+
{/if}
|
|
820
979
|
</ul>
|
|
821
980
|
{/if}
|
|
822
981
|
</div>
|
|
@@ -961,12 +1120,6 @@ function portal(node, params) {
|
|
|
961
1120
|
padding: var(--sms-options-padding);
|
|
962
1121
|
margin: var(--sms-options-margin, inherit);
|
|
963
1122
|
}
|
|
964
|
-
:is(div.multiselect.open) {
|
|
965
|
-
/* increase z-index when open to ensure the dropdown of one <MultiSelect />
|
|
966
|
-
displays above that of another slightly below it on the page */
|
|
967
|
-
/* This z-index is for the div.multiselect itself, portal has its own higher z-index */
|
|
968
|
-
z-index: var(--sms-open-z-index, 4);
|
|
969
|
-
}
|
|
970
1123
|
ul.options.hidden {
|
|
971
1124
|
visibility: hidden;
|
|
972
1125
|
opacity: 0;
|
|
@@ -1006,10 +1159,32 @@ function portal(node, params) {
|
|
|
1006
1159
|
margin-right: 6px;
|
|
1007
1160
|
accent-color: var(--sms-active-color, cornflowerblue);
|
|
1008
1161
|
}
|
|
1162
|
+
/* Select all option styling */
|
|
1163
|
+
ul.options > li.select-all {
|
|
1164
|
+
border-bottom: var(--sms-select-all-border-bottom, 1px solid lightgray);
|
|
1165
|
+
font-weight: var(--sms-select-all-font-weight, 500);
|
|
1166
|
+
color: var(--sms-select-all-color, inherit);
|
|
1167
|
+
background: var(--sms-select-all-bg, transparent);
|
|
1168
|
+
margin-bottom: var(--sms-select-all-margin-bottom, 2pt);
|
|
1169
|
+
}
|
|
1170
|
+
ul.options > li.select-all:hover {
|
|
1171
|
+
background: var(
|
|
1172
|
+
--sms-select-all-hover-bg,
|
|
1173
|
+
var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)))
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1009
1176
|
:is(span.max-select-msg) {
|
|
1010
1177
|
padding: 0 3pt;
|
|
1011
1178
|
}
|
|
1012
1179
|
::highlight(sms-search-matches) {
|
|
1013
1180
|
color: mediumaquamarine;
|
|
1014
1181
|
}
|
|
1182
|
+
/* Loading more indicator for infinite scrolling */
|
|
1183
|
+
ul.options > li.loading-more {
|
|
1184
|
+
display: flex;
|
|
1185
|
+
justify-content: center;
|
|
1186
|
+
align-items: center;
|
|
1187
|
+
padding: 8pt;
|
|
1188
|
+
cursor: default;
|
|
1189
|
+
}
|
|
1015
1190
|
</style>
|