svelte-multiselect 11.3.0 → 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/CodeExample.svelte +2 -4
- package/dist/CopyButton.svelte +3 -2
- package/dist/CopyButton.svelte.d.ts +1 -1
- package/dist/GitHubCorner.svelte +2 -2
- package/dist/MultiSelect.svelte +326 -153
- package/dist/MultiSelect.svelte.d.ts +2 -2
- package/dist/Nav.svelte +57 -60
- package/dist/Nav.svelte.d.ts +5 -5
- package/dist/attachments.d.ts +17 -9
- package/dist/attachments.js +80 -22
- package/dist/types.d.ts +33 -4
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +5 -3
- package/package.json +21 -14
- package/readme.md +84 -6
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,83 +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
|
+
}, 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
|
|
16
17
|
? (Array.isArray(value) ? value : [value])
|
|
17
18
|
: (options
|
|
18
|
-
?.filter((opt) => opt
|
|
19
|
-
.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, portal: portal_params = {},
|
|
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.
|
|
20
55
|
$effect.pre(() => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}); // 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
|
+
});
|
|
26
60
|
$effect.pre(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
});
|
|
32
67
|
let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
|
|
33
|
-
|
|
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)) {
|
|
34
93
|
if (allowUserOptions || loading || disabled || allowEmpty) {
|
|
35
94
|
options = []; // initializing as array avoids errors when component mounts
|
|
36
95
|
}
|
|
37
96
|
else {
|
|
38
97
|
// error on empty options if user is not allowed to create custom options and loading is false
|
|
39
98
|
// and component is not disabled and allowEmpty is false
|
|
40
|
-
console.error(`MultiSelect received no options`);
|
|
99
|
+
console.error(`MultiSelect: received no options`);
|
|
41
100
|
}
|
|
42
101
|
}
|
|
43
102
|
if (maxSelect !== null && maxSelect < 1) {
|
|
44
|
-
console.error(`MultiSelect
|
|
103
|
+
console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
|
|
45
104
|
}
|
|
46
105
|
if (!Array.isArray(selected)) {
|
|
47
|
-
console.error(`MultiSelect
|
|
106
|
+
console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
|
|
48
107
|
}
|
|
49
108
|
if (maxSelect && typeof required === `number` && required > maxSelect) {
|
|
50
|
-
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`);
|
|
51
110
|
}
|
|
52
111
|
if (parseLabelsAsHtml && allowUserOptions) {
|
|
53
|
-
console.warn(`
|
|
112
|
+
console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
|
|
54
113
|
}
|
|
55
114
|
if (sortSelected && selectedOptionsDraggable) {
|
|
56
|
-
console.warn(`MultiSelect
|
|
115
|
+
console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
|
|
57
116
|
`user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
|
|
58
117
|
}
|
|
59
118
|
if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
|
|
60
|
-
console.error(`MultiSelect
|
|
119
|
+
console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
|
|
61
120
|
`This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
|
|
62
121
|
}
|
|
63
122
|
if (maxOptions &&
|
|
64
123
|
(typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
|
|
65
|
-
console.error(`MultiSelect
|
|
124
|
+
console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
|
|
66
125
|
}
|
|
67
126
|
let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
|
|
68
127
|
let window_width = $state(0);
|
|
69
128
|
// options matching the current search text
|
|
70
129
|
$effect.pre(() => {
|
|
71
|
-
|
|
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)) &&
|
|
72
133
|
// remove already selected options from dropdown list unless duplicate selections are allowed
|
|
73
134
|
// or keepSelectedInDropdown is enabled
|
|
74
|
-
(!
|
|
75
|
-
|
|
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
|
+
}
|
|
76
142
|
});
|
|
77
|
-
// raise if matchingOptions[activeIndex] does not yield a value
|
|
78
|
-
if (activeIndex !== null && !matchingOptions[activeIndex]) {
|
|
79
|
-
throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
|
|
80
|
-
}
|
|
81
143
|
// update activeOption when activeIndex changes
|
|
82
144
|
$effect(() => {
|
|
83
145
|
activeOption = matchingOptions[activeIndex ?? -1] ?? null;
|
|
84
146
|
});
|
|
147
|
+
// Helper to check if removing an option would violate minSelect constraint
|
|
148
|
+
const can_remove = $derived(minSelect === null || selected.length > minSelect);
|
|
85
149
|
// toggle an option between selected and unselected states (for keepSelectedInDropdown mode)
|
|
86
150
|
function toggle_option(option_to_toggle, event) {
|
|
87
|
-
const is_currently_selected =
|
|
151
|
+
const is_currently_selected = selected_keys.includes(key(option_to_toggle));
|
|
88
152
|
if (is_currently_selected) {
|
|
89
|
-
if (
|
|
153
|
+
if (can_remove)
|
|
90
154
|
remove(option_to_toggle, event);
|
|
91
|
-
}
|
|
92
155
|
}
|
|
93
156
|
else
|
|
94
157
|
add(option_to_toggle, event);
|
|
@@ -98,25 +161,25 @@ function add(option_to_add, event) {
|
|
|
98
161
|
event.stopPropagation();
|
|
99
162
|
if (maxSelect !== null && selected.length >= maxSelect)
|
|
100
163
|
wiggle = true;
|
|
101
|
-
if (!isNaN(Number(option_to_add)) && typeof
|
|
164
|
+
if (!isNaN(Number(option_to_add)) && typeof selected_labels[0] === `number`) {
|
|
102
165
|
option_to_add = Number(option_to_add); // convert to number if possible
|
|
103
166
|
}
|
|
104
|
-
const is_duplicate =
|
|
167
|
+
const is_duplicate = selected_keys.includes(key(option_to_add));
|
|
105
168
|
if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
|
|
106
169
|
(duplicates || !is_duplicate)) {
|
|
107
|
-
if (!
|
|
170
|
+
if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
|
|
108
171
|
// this has the side-effect of not allowing to user to add the same
|
|
109
172
|
// custom option twice in append mode
|
|
110
173
|
[true, `append`].includes(allowUserOptions) &&
|
|
111
174
|
searchText.length > 0) {
|
|
112
175
|
// user entered text but no options match, so if allowUserOptions = true | 'append', we create
|
|
113
176
|
// a new option from the user-entered text
|
|
114
|
-
if (typeof
|
|
177
|
+
if (typeof effective_options[0] === `object`) {
|
|
115
178
|
// if 1st option is an object, we create new option as object to keep type homogeneity
|
|
116
179
|
option_to_add = { label: searchText };
|
|
117
180
|
}
|
|
118
181
|
else {
|
|
119
|
-
if ([`number`, `undefined`].includes(typeof
|
|
182
|
+
if ([`number`, `undefined`].includes(typeof effective_options[0]) &&
|
|
120
183
|
!isNaN(Number(searchText))) {
|
|
121
184
|
// create new option as number if it parses to a number and 1st option is also number or missing
|
|
122
185
|
option_to_add = Number(searchText);
|
|
@@ -127,46 +190,31 @@ function add(option_to_add, event) {
|
|
|
127
190
|
}
|
|
128
191
|
// Fire oncreate event for all user-created options, regardless of type
|
|
129
192
|
oncreate?.({ option: option_to_add });
|
|
130
|
-
if (allowUserOptions === `append`)
|
|
131
|
-
|
|
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
|
+
}
|
|
132
201
|
}
|
|
133
202
|
if (resetFilterOnAdd)
|
|
134
203
|
searchText = ``; // reset search string on selection
|
|
135
204
|
if ([``, undefined, null].includes(option_to_add)) {
|
|
136
|
-
console.error(`MultiSelect: encountered falsy option
|
|
205
|
+
console.error(`MultiSelect: encountered falsy option`, option_to_add);
|
|
137
206
|
return;
|
|
138
207
|
}
|
|
139
208
|
// for maxSelect = 1 we always replace current option with new one
|
|
140
209
|
if (maxSelect === 1)
|
|
141
210
|
selected = [option_to_add];
|
|
142
211
|
else {
|
|
143
|
-
selected = [...selected, option_to_add];
|
|
144
|
-
if (sortSelected === true) {
|
|
145
|
-
selected = selected.sort((op1, op2) => {
|
|
146
|
-
const [label1, label2] = [get_label(op1), get_label(op2)];
|
|
147
|
-
// coerce to string if labels are numbers
|
|
148
|
-
return `${label1}`.localeCompare(`${label2}`);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
else if (typeof sortSelected === `function`) {
|
|
152
|
-
selected = selected.sort(sortSelected);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const reached_max_select = selected.length >= (maxSelect ?? Infinity);
|
|
156
|
-
const dropdown_should_close = closeDropdownOnSelect === true ||
|
|
157
|
-
closeDropdownOnSelect === `retain-focus` ||
|
|
158
|
-
(closeDropdownOnSelect === `if-mobile` && window_width &&
|
|
159
|
-
window_width < breakpoint);
|
|
160
|
-
const should_retain_focus = closeDropdownOnSelect === `retain-focus`;
|
|
161
|
-
if (reached_max_select || dropdown_should_close) {
|
|
162
|
-
close_dropdown(event, should_retain_focus);
|
|
212
|
+
selected = sort_selected([...selected, option_to_add]);
|
|
163
213
|
}
|
|
164
|
-
|
|
165
|
-
|
|
214
|
+
clear_validity();
|
|
215
|
+
handle_dropdown_after_select(event);
|
|
166
216
|
onadd?.({ option: option_to_add });
|
|
167
217
|
onchange?.({ option: option_to_add, type: `add` });
|
|
168
|
-
invalid = false; // reset error status whenever new items are selected
|
|
169
|
-
form_input?.setCustomValidity(``);
|
|
170
218
|
}
|
|
171
219
|
}
|
|
172
220
|
// remove an option from selected list
|
|
@@ -180,15 +228,15 @@ function remove(option_to_drop, event) {
|
|
|
180
228
|
// if option with label could not be found but allowUserOptions is truthy,
|
|
181
229
|
// assume it was created by user and create corresponding option object
|
|
182
230
|
// on the fly for use as event payload
|
|
183
|
-
const
|
|
184
|
-
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);
|
|
185
233
|
}
|
|
186
234
|
if (option_removed === undefined) {
|
|
187
|
-
|
|
235
|
+
console.error(`MultiSelect: can't remove option ${JSON.stringify(option_to_drop)}, not found in selected list`);
|
|
236
|
+
return;
|
|
188
237
|
}
|
|
189
238
|
selected = [...selected]; // trigger Svelte rerender
|
|
190
|
-
|
|
191
|
-
form_input?.setCustomValidity(``);
|
|
239
|
+
clear_validity();
|
|
192
240
|
onremove?.({ option: option_removed });
|
|
193
241
|
onchange?.({ option: option_removed, type: `remove` });
|
|
194
242
|
}
|
|
@@ -210,6 +258,52 @@ function close_dropdown(event, retain_focus = false) {
|
|
|
210
258
|
activeIndex = null;
|
|
211
259
|
onclose?.({ event });
|
|
212
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
|
+
}
|
|
213
307
|
// handle all keyboard events this component receives
|
|
214
308
|
async function handle_keydown(event) {
|
|
215
309
|
// on escape or tab out of input: close options dropdown and reset search text
|
|
@@ -217,20 +311,17 @@ async function handle_keydown(event) {
|
|
|
217
311
|
event.stopPropagation();
|
|
218
312
|
close_dropdown(event);
|
|
219
313
|
searchText = ``;
|
|
220
|
-
} // on enter key: toggle active option
|
|
314
|
+
} // on enter key: toggle active option
|
|
221
315
|
else if (event.key === `Enter`) {
|
|
222
316
|
event.stopPropagation();
|
|
223
317
|
event.preventDefault(); // prevent enter key from triggering form submission
|
|
224
318
|
if (activeOption) {
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
if (minSelect === null || selected.length > minSelect) {
|
|
319
|
+
if (selected_keys.includes(key(activeOption))) {
|
|
320
|
+
if (can_remove)
|
|
228
321
|
remove(activeOption, event);
|
|
229
|
-
}
|
|
230
322
|
}
|
|
231
323
|
else
|
|
232
|
-
add(activeOption, event);
|
|
233
|
-
searchText = ``;
|
|
324
|
+
add(activeOption, event); // add() handles resetFilterOnAdd internally when successful
|
|
234
325
|
}
|
|
235
326
|
else if (allowUserOptions && searchText.length > 0) {
|
|
236
327
|
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
|
|
@@ -242,59 +333,14 @@ async function handle_keydown(event) {
|
|
|
242
333
|
open_dropdown(event);
|
|
243
334
|
}
|
|
244
335
|
} // on up/down arrow keys: update active option
|
|
245
|
-
else if (
|
|
336
|
+
else if (event.key === `ArrowDown` || event.key === `ArrowUp`) {
|
|
246
337
|
event.stopPropagation();
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
250
|
-
activeIndex = 0;
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
|
|
254
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
255
|
-
// if allowUserOptions is truthy and user entered text but no options match, we make
|
|
256
|
-
// <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
|
|
257
|
-
option_msg_is_active = !option_msg_is_active;
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
else if (activeIndex === null) {
|
|
261
|
-
// if no option is active and no options are matching, do nothing
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
event.preventDefault(); // Prevent scroll only if we handle the key
|
|
265
|
-
// if none of the above special cases apply, we make next/prev option
|
|
266
|
-
// active with wrap around at both ends
|
|
267
|
-
const increment = event.key === `ArrowUp` ? -1 : 1;
|
|
268
|
-
// Include user message in total count if it exists
|
|
269
|
-
const has_user_msg = searchText && ((allowUserOptions && createOptionMsg) ||
|
|
270
|
-
(!duplicates && selected.map(get_label).includes(searchText)) ||
|
|
271
|
-
(matchingOptions.length === 0 && noMatchingOptionsMsg));
|
|
272
|
-
const total_items = matchingOptions.length + (has_user_msg ? 1 : 0);
|
|
273
|
-
activeIndex = (activeIndex + increment) % total_items;
|
|
274
|
-
// in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
|
|
275
|
-
// need to do manual wrap around at 0
|
|
276
|
-
if (activeIndex < 0)
|
|
277
|
-
activeIndex = total_items - 1;
|
|
278
|
-
// Handle user message activation
|
|
279
|
-
if (has_user_msg && activeIndex === matchingOptions.length) {
|
|
280
|
-
option_msg_is_active = true;
|
|
281
|
-
activeOption = null;
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
option_msg_is_active = false;
|
|
285
|
-
activeOption = matchingOptions[activeIndex] ?? null;
|
|
286
|
-
}
|
|
287
|
-
if (autoScroll) {
|
|
288
|
-
await tick();
|
|
289
|
-
const li = document.querySelector(`ul.options > li.active`);
|
|
290
|
-
if (li)
|
|
291
|
-
li.scrollIntoViewIfNeeded?.();
|
|
292
|
-
}
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
await handle_arrow_navigation(event.key === `ArrowUp` ? -1 : 1);
|
|
293
340
|
} // on backspace key: remove last selected option
|
|
294
341
|
else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
|
|
295
342
|
event.stopPropagation();
|
|
296
|
-
|
|
297
|
-
if (minSelect === null || selected.length > minSelect) {
|
|
343
|
+
if (can_remove) {
|
|
298
344
|
const last_option = selected.at(-1);
|
|
299
345
|
if (last_option)
|
|
300
346
|
remove(last_option, event);
|
|
@@ -314,21 +360,41 @@ function remove_all(event) {
|
|
|
314
360
|
// If no minSelect constraint, remove all
|
|
315
361
|
removed_options = selected;
|
|
316
362
|
selected = [];
|
|
317
|
-
searchText = ``;
|
|
363
|
+
searchText = ``; // always clear on remove all (resetFilterOnAdd only applies to add operations)
|
|
318
364
|
}
|
|
319
365
|
else if (selected.length > minSelect) {
|
|
320
366
|
// Keep the first minSelect items
|
|
321
367
|
removed_options = selected.slice(minSelect);
|
|
322
368
|
selected = selected.slice(0, minSelect);
|
|
323
|
-
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` });
|
|
324
393
|
}
|
|
325
|
-
onremoveAll?.({ options: removed_options });
|
|
326
|
-
onchange?.({ options: selected, type: `removeAll` });
|
|
327
|
-
// If selected.length <= minSelect, do nothing (can't remove any more)
|
|
328
394
|
}
|
|
329
|
-
let is_selected = $derived((label) =>
|
|
395
|
+
let is_selected = $derived((label) => selected_labels.includes(label));
|
|
330
396
|
const if_enter_or_space = (handler) => (event) => {
|
|
331
|
-
if (
|
|
397
|
+
if (event.key === `Enter` || event.code === `Space`) {
|
|
332
398
|
event.preventDefault();
|
|
333
399
|
handler(event);
|
|
334
400
|
}
|
|
@@ -466,6 +532,74 @@ function portal(node, params) {
|
|
|
466
532
|
};
|
|
467
533
|
}
|
|
468
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
|
+
}
|
|
469
603
|
</script>
|
|
470
604
|
|
|
471
605
|
<svelte:window
|
|
@@ -525,7 +659,7 @@ function portal(node, params) {
|
|
|
525
659
|
aria-label="selected options"
|
|
526
660
|
style={ulSelectedStyle}
|
|
527
661
|
>
|
|
528
|
-
{#each selected as option, idx (duplicates ?
|
|
662
|
+
{#each selected as option, idx (duplicates ? `${key(option)}-${idx}` : key(option))}
|
|
529
663
|
{@const selectedOptionStyle =
|
|
530
664
|
[get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(
|
|
531
665
|
` `,
|
|
@@ -535,7 +669,7 @@ function portal(node, params) {
|
|
|
535
669
|
class={liSelectedClass}
|
|
536
670
|
role="option"
|
|
537
671
|
aria-selected="true"
|
|
538
|
-
animate:flip={
|
|
672
|
+
animate:flip={selectedFlipParams}
|
|
539
673
|
draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
|
|
540
674
|
ondragstart={dragstart(idx)}
|
|
541
675
|
ondragover={(event) => {
|
|
@@ -545,6 +679,7 @@ function portal(node, params) {
|
|
|
545
679
|
ondragenter={() => (drag_idx = idx)}
|
|
546
680
|
class:active={drag_idx === idx}
|
|
547
681
|
style={selectedOptionStyle}
|
|
682
|
+
onmouseup={(event) => event.stopPropagation()}
|
|
548
683
|
>
|
|
549
684
|
{#if selectedItem}
|
|
550
685
|
{@render selectedItem({
|
|
@@ -561,7 +696,7 @@ function portal(node, params) {
|
|
|
561
696
|
{:else}
|
|
562
697
|
{get_label(option)}
|
|
563
698
|
{/if}
|
|
564
|
-
{#if !disabled &&
|
|
699
|
+
{#if !disabled && can_remove}
|
|
565
700
|
<button
|
|
566
701
|
onclick={(event) => remove(option, event)}
|
|
567
702
|
onkeydown={if_enter_or_space((event) => remove(option, event))}
|
|
@@ -588,7 +723,7 @@ function portal(node, params) {
|
|
|
588
723
|
{autocomplete}
|
|
589
724
|
{inputmode}
|
|
590
725
|
{pattern}
|
|
591
|
-
placeholder={selected.length === 0 ?
|
|
726
|
+
placeholder={selected.length === 0 || placeholder_persistent ? placeholder_text : null}
|
|
592
727
|
aria-invalid={invalid ? `true` : null}
|
|
593
728
|
ondrop={() => false}
|
|
594
729
|
onmouseup={open_dropdown}
|
|
@@ -611,7 +746,7 @@ function portal(node, params) {
|
|
|
611
746
|
disabled,
|
|
612
747
|
invalid,
|
|
613
748
|
id,
|
|
614
|
-
placeholder,
|
|
749
|
+
placeholder: placeholder_text,
|
|
615
750
|
open,
|
|
616
751
|
required,
|
|
617
752
|
})}
|
|
@@ -660,7 +795,8 @@ function portal(node, params) {
|
|
|
660
795
|
{/if}
|
|
661
796
|
|
|
662
797
|
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
|
|
663
|
-
{#if (searchText && noMatchingOptionsMsg) ||
|
|
798
|
+
{#if (searchText && noMatchingOptionsMsg) || effective_options.length > 0 ||
|
|
799
|
+
loadOptions}
|
|
664
800
|
<ul
|
|
665
801
|
use:portal={{ target_node: outerDiv, ...portal_params }}
|
|
666
802
|
{@attach highlight_matches({
|
|
@@ -682,14 +818,30 @@ function portal(node, params) {
|
|
|
682
818
|
aria-disabled={disabled ? `true` : null}
|
|
683
819
|
bind:this={ul_options}
|
|
684
820
|
style={ulOptionsStyle}
|
|
821
|
+
onscroll={handle_options_scroll}
|
|
822
|
+
onmousemove={() => (ignore_hover = false)}
|
|
685
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}
|
|
686
838
|
{#each matchingOptions.slice(
|
|
687
839
|
0,
|
|
688
840
|
maxOptions == null ? Infinity : Math.max(0, maxOptions),
|
|
689
841
|
) as
|
|
690
842
|
option_item,
|
|
691
843
|
idx
|
|
692
|
-
(duplicates ?
|
|
844
|
+
(duplicates ? `${key(option_item)}-${idx}` : key(option_item))
|
|
693
845
|
}
|
|
694
846
|
{@const {
|
|
695
847
|
label,
|
|
@@ -697,7 +849,7 @@ function portal(node, params) {
|
|
|
697
849
|
title = null,
|
|
698
850
|
selectedTitle = null,
|
|
699
851
|
disabledTitle = defaultDisabledTitle,
|
|
700
|
-
} = option_item
|
|
852
|
+
} = is_object(option_item) ? option_item : { label: option_item }}
|
|
701
853
|
{@const active = activeIndex === idx}
|
|
702
854
|
{@const selected = is_selected(label)}
|
|
703
855
|
{@const optionStyle =
|
|
@@ -717,7 +869,7 @@ function portal(node, params) {
|
|
|
717
869
|
class:disabled
|
|
718
870
|
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
719
871
|
onmouseover={() => {
|
|
720
|
-
if (!disabled) activeIndex = idx
|
|
872
|
+
if (!disabled && !ignore_hover) activeIndex = idx
|
|
721
873
|
}}
|
|
722
874
|
onfocus={() => {
|
|
723
875
|
if (!disabled) activeIndex = idx
|
|
@@ -760,18 +912,18 @@ function portal(node, params) {
|
|
|
760
912
|
</li>
|
|
761
913
|
{/each}
|
|
762
914
|
{#if searchText}
|
|
763
|
-
{@const text_input_is_duplicate =
|
|
915
|
+
{@const text_input_is_duplicate = selected_labels.includes(searchText)}
|
|
764
916
|
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
|
|
765
917
|
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
|
|
766
918
|
{@const no_match = Boolean(matchingOptions?.length === 0 && noMatchingOptionsMsg) &&
|
|
767
919
|
`no-match`}
|
|
768
920
|
{@const msgType = is_dupe || can_create || no_match}
|
|
769
|
-
{
|
|
770
|
-
{@const msg = {
|
|
921
|
+
{@const msg = msgType && {
|
|
771
922
|
dupe: duplicateOptionMsg,
|
|
772
923
|
create: createOptionMsg,
|
|
773
924
|
'no-match': noMatchingOptionsMsg,
|
|
774
925
|
}[msgType]}
|
|
926
|
+
{#if msg}
|
|
775
927
|
<li
|
|
776
928
|
onclick={(event) => {
|
|
777
929
|
if (msgType === `create` && allowUserOptions) {
|
|
@@ -794,7 +946,7 @@ function portal(node, params) {
|
|
|
794
946
|
? duplicateOptionMsg
|
|
795
947
|
: ``}
|
|
796
948
|
class:active={option_msg_is_active}
|
|
797
|
-
onmouseover={() => (option_msg_is_active = true)}
|
|
949
|
+
onmouseover={() => !ignore_hover && (option_msg_is_active = true)}
|
|
798
950
|
onfocus={() => (option_msg_is_active = true)}
|
|
799
951
|
onmouseout={() => (option_msg_is_active = false)}
|
|
800
952
|
onblur={() => (option_msg_is_active = false)}
|
|
@@ -819,6 +971,11 @@ function portal(node, params) {
|
|
|
819
971
|
</li>
|
|
820
972
|
{/if}
|
|
821
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}
|
|
822
979
|
</ul>
|
|
823
980
|
{/if}
|
|
824
981
|
</div>
|
|
@@ -963,12 +1120,6 @@ function portal(node, params) {
|
|
|
963
1120
|
padding: var(--sms-options-padding);
|
|
964
1121
|
margin: var(--sms-options-margin, inherit);
|
|
965
1122
|
}
|
|
966
|
-
:is(div.multiselect.open) {
|
|
967
|
-
/* increase z-index when open to ensure the dropdown of one <MultiSelect />
|
|
968
|
-
displays above that of another slightly below it on the page */
|
|
969
|
-
/* This z-index is for the div.multiselect itself, portal has its own higher z-index */
|
|
970
|
-
z-index: var(--sms-open-z-index, 4);
|
|
971
|
-
}
|
|
972
1123
|
ul.options.hidden {
|
|
973
1124
|
visibility: hidden;
|
|
974
1125
|
opacity: 0;
|
|
@@ -1008,10 +1159,32 @@ function portal(node, params) {
|
|
|
1008
1159
|
margin-right: 6px;
|
|
1009
1160
|
accent-color: var(--sms-active-color, cornflowerblue);
|
|
1010
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
|
+
}
|
|
1011
1176
|
:is(span.max-select-msg) {
|
|
1012
1177
|
padding: 0 3pt;
|
|
1013
1178
|
}
|
|
1014
1179
|
::highlight(sms-search-matches) {
|
|
1015
1180
|
color: mediumaquamarine;
|
|
1016
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
|
+
}
|
|
1017
1190
|
</style>
|