svelte-multiselect 11.0.0-rc.1 → 11.1.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.
@@ -1,94 +1,30 @@
1
- <script>import { createEventDispatcher, tick } from 'svelte';
1
+ <script lang="ts">import { browser } from '$app/environment';
2
+ import { tick } from 'svelte';
2
3
  import { flip } from 'svelte/animate';
3
4
  import CircleSpinner from './CircleSpinner.svelte';
4
5
  import Wiggle from './Wiggle.svelte';
5
6
  import { CrossIcon, DisabledIcon, ExpandIcon } from './icons';
6
- import { get_label, get_style } from './utils';
7
- export let activeIndex = null;
8
- export let activeOption = null;
9
- export let createOptionMsg = `Create this option...`;
10
- export let allowUserOptions = false;
11
- export let allowEmpty = false; // added for https://github.com/janosh/svelte-multiselect/issues/192
12
- export let autocomplete = `off`;
13
- export let autoScroll = true;
14
- export let breakpoint = 800; // any screen with more horizontal pixels is considered desktop, below is mobile
15
- export let defaultDisabledTitle = `This option is disabled`;
16
- export let disabled = false;
17
- export let disabledInputTitle = `This input is disabled`;
18
- // prettier-ignore
19
- export let duplicateOptionMsg = `This option is already selected`;
20
- export let duplicates = false; // whether to allow duplicate options
21
- // takes two options and returns true if they are equal
22
- // case-insensitive equality comparison after string coercion and looks only at the `label` key of object options by default
23
- export let key = (opt) => `${get_label(opt)}`.toLowerCase();
24
- export let filterFunc = (opt, searchText) => {
7
+ import { get_label, get_style, highlight_matching_nodes } from './utils';
8
+ 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, key = (opt) => `${get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
25
9
  if (!searchText)
26
10
  return true;
27
11
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
28
- };
29
- export let closeDropdownOnSelect = `desktop`;
30
- export let form_input = null;
31
- export let highlightMatches = true;
32
- export let id = null;
33
- export let input = null;
34
- export let inputClass = ``;
35
- export let inputStyle = null;
36
- export let inputmode = null;
37
- export let invalid = false;
38
- export let liActiveOptionClass = ``;
39
- export let liActiveUserMsgClass = ``;
40
- export let liOptionClass = ``;
41
- export let liOptionStyle = null;
42
- export let liSelectedClass = ``;
43
- export let liSelectedStyle = null;
44
- export let liUserMsgClass = ``;
45
- export let loading = false;
46
- export let matchingOptions = [];
47
- export let maxOptions = undefined;
48
- export let maxSelect = null; // null means there is no upper limit for selected.length
49
- export let maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``);
50
- export let maxSelectMsgClass = ``;
51
- export let name = null;
52
- export let noMatchingOptionsMsg = `No matching options`;
53
- export let open = false;
54
- export let options;
55
- export let outerDiv = null;
56
- export let outerDivClass = ``;
57
- export let parseLabelsAsHtml = false; // should not be combined with allowUserOptions!
58
- export let pattern = null;
59
- export let placeholder = null;
60
- export let removeAllTitle = `Remove all`;
61
- export let removeBtnTitle = `Remove`;
62
- export let minSelect = null; // null means there is no lower limit for selected.length
63
- export let required = false;
64
- export let resetFilterOnAdd = true;
65
- export let searchText = ``;
66
- export let selected = options
12
+ }, closeDropdownOnSelect = `desktop`, 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 = 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(``), selected = $bindable(options
67
13
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
68
- .slice(0, maxSelect ?? undefined) ?? []; // don't allow more than maxSelect preselected options
69
- export let sortSelected = false;
70
- export let selectedOptionsDraggable = !sortSelected;
71
- export let style = null;
72
- export let ulOptionsClass = ``;
73
- export let ulSelectedClass = ``;
74
- export let ulSelectedStyle = null;
75
- export let ulOptionsStyle = null;
76
- export let value = null;
77
- const selected_to_value = (selected) => {
78
- value = maxSelect === 1 ? selected[0] ?? null : selected;
79
- };
80
- const value_to_selected = (value) => {
14
+ .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(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 = {}, ...rest } = $props();
15
+ $effect.pre(() => {
16
+ // if maxSelect=1, value is the single item in selected (or null if selected is empty)
17
+ // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
18
+ // https://github.com/janosh/svelte-multiselect/issues/136
19
+ value = maxSelect === 1 ? (selected[0] ?? null) : selected;
20
+ }); // sync selected updates to value
21
+ $effect.pre(() => {
81
22
  if (maxSelect === 1)
82
23
  selected = value ? [value] : [];
83
24
  else
84
25
  selected = value ?? [];
85
- };
86
- // if maxSelect=1, value is the single item in selected (or null if selected is empty)
87
- // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
88
- // https://github.com/janosh/svelte-multiselect/issues/136
89
- $: selected_to_value(selected);
90
- $: value_to_selected(value);
91
- let wiggle = false; // controls wiggle animation when user tries to exceed maxSelect
26
+ }); // sync value updates to selected
27
+ let wiggle = $state(false); // controls wiggle animation when user tries to exceed maxSelect
92
28
  if (!(options?.length > 0)) {
93
29
  if (allowUserOptions || loading || disabled || allowEmpty) {
94
30
  options = []; // initializing as array avoids errors when component mounts
@@ -123,30 +59,34 @@ if (maxOptions &&
123
59
  (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
124
60
  console.error(`MultiSelect's maxOptions must be undefined or a positive integer, got ${maxOptions}`);
125
61
  }
126
- const dispatch = createEventDispatcher();
127
- let option_msg_is_active = false; // controls active state of <li>{createOptionMsg}</li>
128
- let window_width;
62
+ let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
63
+ let window_width = $state(0);
129
64
  // options matching the current search text
130
- $: matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
131
- // remove already selected options from dropdown list unless duplicate selections are allowed
132
- (!selected.map(key).includes(key(opt)) || duplicates));
65
+ $effect.pre(() => {
66
+ matchingOptions = options.filter((opt) => filterFunc(opt, searchText) &&
67
+ // remove already selected options from dropdown list unless duplicate selections are allowed
68
+ (!selected.map(key).includes(key(opt)) || duplicates));
69
+ });
133
70
  // raise if matchingOptions[activeIndex] does not yield a value
134
71
  if (activeIndex !== null && !matchingOptions[activeIndex]) {
135
72
  throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`;
136
73
  }
137
74
  // update activeOption when activeIndex changes
138
- $: activeOption = matchingOptions[activeIndex ?? -1] ?? null;
75
+ $effect(() => {
76
+ activeOption = matchingOptions[activeIndex ?? -1] ?? null;
77
+ });
139
78
  // add an option to selected list
140
- function add(option, event) {
141
- if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
79
+ function add(option_to_add, event) {
80
+ event.stopPropagation();
81
+ if (maxSelect !== null && selected.length >= maxSelect)
142
82
  wiggle = true;
143
- if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
144
- option = Number(option); // convert to number if possible
83
+ if (!isNaN(Number(option_to_add)) && typeof selected.map(get_label)[0] === `number`) {
84
+ option_to_add = Number(option_to_add); // convert to number if possible
145
85
  }
146
- const is_duplicate = selected.map(key).includes(key(option));
86
+ const is_duplicate = selected.map(key).includes(key(option_to_add));
147
87
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
148
88
  (duplicates || !is_duplicate)) {
149
- if (!options.includes(option) && // first check if we find option in the options list
89
+ if (!options.includes(option_to_add) && // first check if we find option in the options list
150
90
  // this has the side-effect of not allowing to user to add the same
151
91
  // custom option twice in append mode
152
92
  [true, `append`].includes(allowUserOptions) &&
@@ -155,34 +95,34 @@ function add(option, event) {
155
95
  // a new option from the user-entered text
156
96
  if (typeof options[0] === `object`) {
157
97
  // if 1st option is an object, we create new option as object to keep type homogeneity
158
- option = { label: searchText };
98
+ option_to_add = { label: searchText };
159
99
  }
160
100
  else {
161
101
  if ([`number`, `undefined`].includes(typeof options[0]) &&
162
102
  !isNaN(Number(searchText))) {
163
103
  // create new option as number if it parses to a number and 1st option is also number or missing
164
- option = Number(searchText);
104
+ option_to_add = Number(searchText);
165
105
  }
166
106
  else {
167
- option = searchText; // else create custom option as string
107
+ option_to_add = searchText; // else create custom option as string
168
108
  }
169
- dispatch(`create`, { option });
109
+ oncreate?.({ option: option_to_add });
170
110
  }
171
111
  if (allowUserOptions === `append`)
172
- options = [...options, option];
112
+ options = [...options, option_to_add];
173
113
  }
174
114
  if (resetFilterOnAdd)
175
115
  searchText = ``; // reset search string on selection
176
- if ([``, undefined, null].includes(option)) {
177
- console.error(`MultiSelect: encountered falsy option ${option}`);
116
+ if ([``, undefined, null].includes(option_to_add)) {
117
+ console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
178
118
  return;
179
119
  }
180
120
  if (maxSelect === 1) {
181
121
  // for maxSelect = 1 we always replace current option with new one
182
- selected = [option];
122
+ selected = [option_to_add];
183
123
  }
184
124
  else {
185
- selected = [...selected, option];
125
+ selected = [...selected, option_to_add];
186
126
  if (sortSelected === true) {
187
127
  selected = selected.sort((op1, op2) => {
188
128
  const [label1, label2] = [get_label(op1), get_label(op2)];
@@ -196,42 +136,44 @@ function add(option, event) {
196
136
  }
197
137
  const reached_max_select = selected.length === maxSelect;
198
138
  const dropdown_should_close = closeDropdownOnSelect === true ||
199
- (closeDropdownOnSelect === `desktop` && window_width < breakpoint);
139
+ (closeDropdownOnSelect === `desktop` && window_width && window_width < breakpoint);
200
140
  if (reached_max_select || dropdown_should_close) {
201
141
  close_dropdown(event);
202
142
  }
203
143
  else if (!dropdown_should_close) {
204
144
  input?.focus();
205
145
  }
206
- dispatch(`add`, { option });
207
- dispatch(`change`, { option, type: `add` });
146
+ onadd?.({ option: option_to_add });
147
+ onchange?.({ option: option_to_add, type: `add` });
208
148
  invalid = false; // reset error status whenever new items are selected
209
149
  form_input?.setCustomValidity(``);
210
150
  }
211
151
  }
212
152
  // remove an option from selected list
213
- function remove(to_remove) {
153
+ function remove(option_to_drop, event) {
154
+ event.stopPropagation();
214
155
  if (selected.length === 0)
215
156
  return;
216
- const idx = selected.findIndex((opt) => key(opt) === key(to_remove));
217
- let [option] = selected.splice(idx, 1); // remove option from selected list
218
- if (option === undefined && allowUserOptions) {
157
+ const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
158
+ let [option_removed] = selected.splice(idx, 1); // remove option from selected list
159
+ if (option_removed === undefined && allowUserOptions) {
219
160
  // if option with label could not be found but allowUserOptions is truthy,
220
161
  // assume it was created by user and create corresponding option object
221
162
  // on the fly for use as event payload
222
163
  const other_ops_type = typeof options[0];
223
- option = (other_ops_type ? { label: to_remove } : to_remove);
164
+ option_removed = (other_ops_type ? { label: option_to_drop } : option_to_drop);
224
165
  }
225
- if (option === undefined) {
226
- return console.error(`Multiselect can't remove selected option ${JSON.stringify(to_remove)}, not found in selected list`);
166
+ if (option_removed === undefined) {
167
+ return console.error(`Multiselect can't remove selected option ${JSON.stringify(option_to_drop)}, not found in selected list`);
227
168
  }
228
169
  selected = [...selected]; // trigger Svelte rerender
229
170
  invalid = false; // reset error status whenever items are removed
230
171
  form_input?.setCustomValidity(``);
231
- dispatch(`remove`, { option });
232
- dispatch(`change`, { option, type: `remove` });
172
+ onremove?.({ option: option_removed });
173
+ onchange?.({ option: option_removed, type: `remove` });
233
174
  }
234
175
  function open_dropdown(event) {
176
+ event.stopPropagation();
235
177
  if (disabled)
236
178
  return;
237
179
  open = true;
@@ -239,45 +181,54 @@ function open_dropdown(event) {
239
181
  // avoid double-focussing input when event that opened dropdown was already input FocusEvent
240
182
  input?.focus();
241
183
  }
242
- dispatch(`open`, { event });
184
+ onopen?.({ event });
243
185
  }
244
186
  function close_dropdown(event) {
245
187
  open = false;
246
188
  input?.blur();
247
189
  activeIndex = null;
248
- dispatch(`close`, { event });
190
+ onclose?.({ event });
249
191
  }
250
192
  // handle all keyboard events this component receives
251
193
  async function handle_keydown(event) {
252
194
  // on escape or tab out of input: close options dropdown and reset search text
253
195
  if (event.key === `Escape` || event.key === `Tab`) {
196
+ event.stopPropagation();
254
197
  close_dropdown(event);
255
198
  searchText = ``;
256
199
  }
257
200
  // on enter key: toggle active option and reset search text
258
201
  else if (event.key === `Enter`) {
202
+ event.stopPropagation();
259
203
  event.preventDefault(); // prevent enter key from triggering form submission
260
204
  if (activeOption) {
261
- selected.includes(activeOption) ? remove(activeOption) : add(activeOption, event);
205
+ if (selected.includes(activeOption))
206
+ remove(activeOption, event);
207
+ else
208
+ add(activeOption, event);
262
209
  searchText = ``;
263
210
  }
264
211
  else if (allowUserOptions && searchText.length > 0) {
265
212
  // user entered text but no options match, so if allowUserOptions is truthy, we create new option
266
213
  add(searchText, event);
267
214
  }
268
- // no active option and no search text means the options dropdown is closed
269
- // in which case enter means open it
270
- else
215
+ else {
216
+ // no active option and no search text means the options dropdown is closed
217
+ // in which case enter means open it
271
218
  open_dropdown(event);
219
+ }
272
220
  }
273
221
  // on up/down arrow keys: update active option
274
222
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
223
+ event.stopPropagation();
275
224
  // if no option is active yet, but there are matching options, make first one active
276
225
  if (activeIndex === null && matchingOptions.length > 0) {
226
+ event.preventDefault(); // Prevent scroll only if we handle the key
277
227
  activeIndex = 0;
278
228
  return;
279
229
  }
280
230
  else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
231
+ event.preventDefault(); // Prevent scroll only if we handle the key
281
232
  // if allowUserOptions is truthy and user entered text but no options match, we make
282
233
  // <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
283
234
  option_msg_is_active = !option_msg_is_active;
@@ -287,7 +238,7 @@ async function handle_keydown(event) {
287
238
  // if no option is active and no options are matching, do nothing
288
239
  return;
289
240
  }
290
- event.preventDefault();
241
+ event.preventDefault(); // Prevent scroll only if we handle the key
291
242
  // if none of the above special cases apply, we make next/prev option
292
243
  // active with wrap around at both ends
293
244
  const increment = event.key === `ArrowUp` ? -1 : 1;
@@ -305,24 +256,28 @@ async function handle_keydown(event) {
305
256
  }
306
257
  // on backspace key: remove last selected option
307
258
  else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
308
- remove(selected.at(-1));
259
+ event.stopPropagation();
260
+ // Don't prevent default, allow normal backspace behavior if not removing
261
+ remove(selected.at(-1), event);
309
262
  }
310
263
  // make first matching option active on any keypress (if none of the above special cases match)
311
- else if (matchingOptions.length > 0) {
264
+ else if (matchingOptions.length > 0 && activeIndex === null) {
265
+ // Don't stop propagation or prevent default here, allow normal character input
312
266
  activeIndex = 0;
313
267
  }
314
268
  }
315
- function remove_all() {
316
- dispatch(`removeAll`, { options: selected });
317
- dispatch(`change`, { options: selected, type: `removeAll` });
269
+ function remove_all(event) {
270
+ event.stopPropagation();
271
+ onremoveAll?.({ options: selected });
272
+ onchange?.({ options: selected, type: `removeAll` });
318
273
  selected = [];
319
274
  searchText = ``;
320
275
  }
321
- $: is_selected = (label) => selected.map(get_label).includes(label);
276
+ let is_selected = $derived((label) => selected.map(get_label).includes(label));
322
277
  const if_enter_or_space = (handler) => (event) => {
323
278
  if ([`Enter`, `Space`].includes(event.code)) {
324
279
  event.preventDefault();
325
- handler();
280
+ handler(event);
326
281
  }
327
282
  };
328
283
  function on_click_outside(event) {
@@ -330,7 +285,7 @@ function on_click_outside(event) {
330
285
  close_dropdown(event);
331
286
  }
332
287
  }
333
- let drag_idx = null;
288
+ let drag_idx = $state(null);
334
289
  // event handlers enable dragging to reorder selected options
335
290
  const drop = (target_idx) => (event) => {
336
291
  if (!event.dataTransfer)
@@ -357,62 +312,83 @@ const dragstart = (idx) => (event) => {
357
312
  event.dataTransfer.dropEffect = `move`;
358
313
  event.dataTransfer.setData(`text/plain`, `${idx}`);
359
314
  };
360
- let ul_options;
315
+ let ul_options = $state();
361
316
  // highlight text matching user-entered search text in available options
362
317
  function highlight_matching_options(event) {
363
- if (!highlightMatches || typeof CSS == `undefined` || !CSS.highlights)
364
- return; // abort if CSS highlight API not supported
365
- // clear previous ranges from HighlightRegistry
366
- CSS.highlights.clear();
318
+ if (!highlightMatches || !ul_options)
319
+ return;
367
320
  // get input's search query
368
321
  const query = event?.target?.value.trim().toLowerCase();
369
322
  if (!query)
370
323
  return;
371
- const tree_walker = document.createTreeWalker(ul_options, NodeFilter.SHOW_TEXT, {
372
- acceptNode: (node) => {
373
- // don't highlight text in the "no matching options" message
374
- if (node?.textContent === noMatchingOptionsMsg)
375
- return NodeFilter.FILTER_REJECT;
376
- return NodeFilter.FILTER_ACCEPT;
377
- },
378
- });
379
- const text_nodes = [];
380
- let current_node = tree_walker.nextNode();
381
- while (current_node) {
382
- text_nodes.push(current_node);
383
- current_node = tree_walker.nextNode();
384
- }
385
- // iterate over all text nodes and find matches
386
- const ranges = text_nodes.map((el) => {
387
- const text = el.textContent?.toLowerCase();
388
- const indices = [];
389
- let start_pos = 0;
390
- while (text && start_pos < text.length) {
391
- const index = text.indexOf(query, start_pos);
392
- if (index === -1)
393
- break;
394
- indices.push(index);
395
- start_pos = index + query.length;
396
- }
397
- // create range object for each str found in the text node
398
- return indices.map((index) => {
399
- const range = new Range();
400
- range.setStart(el, index);
401
- range.setEnd(el, index + query.length);
402
- return range;
403
- });
404
- });
405
- // create Highlight object from ranges and add to registry
406
- CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()));
324
+ highlight_matching_nodes(ul_options, query, noMatchingOptionsMsg);
407
325
  }
326
+ const handle_input_keydown = (event) => {
327
+ handle_keydown(event); // Restore internal logic
328
+ // Call original forwarded handler
329
+ onkeydown?.(event);
330
+ };
331
+ const handle_input_focus = (event) => {
332
+ open_dropdown(event); // Internal logic
333
+ // Call original forwarded handler
334
+ onfocus?.(event);
335
+ };
408
336
  // reset form validation when required prop changes
409
337
  // https://github.com/janosh/svelte-multiselect/issues/285
410
- $: required, form_input?.setCustomValidity(``);
338
+ $effect.pre(() => {
339
+ required = required; // trigger effect when required changes
340
+ form_input?.setCustomValidity(``);
341
+ });
342
+ function portal(node, params) {
343
+ let { target_node, active } = params;
344
+ if (!active)
345
+ return;
346
+ let render_in_place = !browser || !document.body.contains(node);
347
+ if (!render_in_place) {
348
+ document.body.appendChild(node);
349
+ node.style.position = `fixed`;
350
+ const update_position = () => {
351
+ if (!target_node || !open)
352
+ return (node.hidden = true);
353
+ const rect = target_node.getBoundingClientRect();
354
+ node.style.left = `${rect.left}px`;
355
+ node.style.top = `${rect.bottom}px`;
356
+ node.style.width = `${rect.width}px`;
357
+ node.hidden = false;
358
+ };
359
+ if (open)
360
+ tick().then(update_position);
361
+ window.addEventListener(`scroll`, update_position, true);
362
+ window.addEventListener(`resize`, update_position);
363
+ $effect.pre(() => {
364
+ if (open && target_node)
365
+ update_position();
366
+ else
367
+ node.hidden = true;
368
+ });
369
+ return {
370
+ update(params) {
371
+ target_node = params.target_node;
372
+ render_in_place = !browser || !document.body.contains(node);
373
+ if (open && !render_in_place && target_node)
374
+ tick().then(update_position);
375
+ else if (!open || !target_node)
376
+ node.hidden = true;
377
+ },
378
+ destroy() {
379
+ if (!render_in_place)
380
+ node.remove();
381
+ window.removeEventListener(`scroll`, update_position, true);
382
+ window.removeEventListener(`resize`, update_position);
383
+ },
384
+ };
385
+ }
386
+ }
411
387
  </script>
412
388
 
413
389
  <svelte:window
414
- on:click={on_click_outside}
415
- on:touchstart={on_click_outside}
390
+ onclick={on_click_outside}
391
+ ontouchstart={on_click_outside}
416
392
  bind:innerWidth={window_width}
417
393
  />
418
394
 
@@ -422,8 +398,8 @@ $: required, form_input?.setCustomValidity(``);
422
398
  class:single={maxSelect === 1}
423
399
  class:open
424
400
  class:invalid
425
- class="multiselect {outerDivClass}"
426
- on:mouseup|stopPropagation={open_dropdown}
401
+ class="multiselect {outerDivClass} {rest.class ?? ``}"
402
+ onmouseup={open_dropdown}
427
403
  title={disabled ? disabledInputTitle : null}
428
404
  data-id={id}
429
405
  role="searchbox"
@@ -434,14 +410,14 @@ $: required, form_input?.setCustomValidity(``);
434
410
  <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
435
411
  <input
436
412
  {name}
437
- {required}
413
+ required={Boolean(required)}
438
414
  value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
439
415
  tabindex="-1"
440
416
  aria-hidden="true"
441
417
  aria-label="ignore this, used only to prevent form submission if select is required but empty"
442
418
  class="form-control"
443
419
  bind:this={form_input}
444
- on:invalid={() => {
420
+ oninvalid={() => {
445
421
  invalid = true
446
422
  let msg
447
423
  if (maxSelect && maxSelect > 1 && Number(required) > 1) {
@@ -454,49 +430,63 @@ $: required, form_input?.setCustomValidity(``);
454
430
  form_input?.setCustomValidity(msg)
455
431
  }}
456
432
  />
457
- <slot name="expand-icon" {open}>
433
+ {#if expandIcon}
434
+ {@render expandIcon({ open })}
435
+ {:else}
458
436
  <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt; cursor: pointer;" />
459
- </slot>
437
+ {/if}
460
438
  <ul
461
439
  class="selected {ulSelectedClass}"
462
440
  aria-label="selected options"
463
441
  style={ulSelectedStyle}
464
442
  >
465
443
  {#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
444
+ {@const selectedOptionStyle =
445
+ [get_style(option, `selected`), liSelectedStyle].filter(Boolean).join(` `) ||
446
+ null}
466
447
  <li
467
448
  class={liSelectedClass}
468
449
  role="option"
469
450
  aria-selected="true"
470
451
  animate:flip={{ duration: 100 }}
471
452
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
472
- on:dragstart={dragstart(idx)}
473
- on:drop|preventDefault={drop(idx)}
474
- on:dragenter={() => (drag_idx = idx)}
475
- on:dragover|preventDefault
453
+ ondragstart={dragstart(idx)}
454
+ ondragover={(event) => {
455
+ event.preventDefault() // needed for ondrop to fire
456
+ }}
457
+ ondrop={drop(idx)}
458
+ ondragenter={() => (drag_idx = idx)}
476
459
  class:active={drag_idx === idx}
477
- style="{get_style(option, `selected`)} {liSelectedStyle}"
460
+ style={selectedOptionStyle}
478
461
  >
479
- <!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
480
- <slot name="selected" {option} {idx}>
481
- <slot {option} {idx}>
482
- {#if parseLabelsAsHtml}
483
- {@html get_label(option)}
484
- {:else}
485
- {get_label(option)}
486
- {/if}
487
- </slot>
488
- </slot>
462
+ {#if selectedItem}
463
+ {@render selectedItem({
464
+ option,
465
+ idx,
466
+ })}
467
+ {:else if children}
468
+ {@render children({
469
+ option,
470
+ idx,
471
+ })}
472
+ {:else if parseLabelsAsHtml}
473
+ {@html get_label(option)}
474
+ {:else}
475
+ {get_label(option)}
476
+ {/if}
489
477
  {#if !disabled && (minSelect === null || selected.length > minSelect)}
490
478
  <button
491
- on:mouseup|stopPropagation={() => remove(option)}
492
- on:keydown={if_enter_or_space(() => remove(option))}
479
+ onclick={(event) => remove(option, event)}
480
+ onkeydown={if_enter_or_space((event) => remove(option, event))}
493
481
  type="button"
494
482
  title="{removeBtnTitle} {get_label(option)}"
495
483
  class="remove"
496
484
  >
497
- <slot name="remove-icon">
485
+ {#if removeIcon}
486
+ {@render removeIcon()}
487
+ {:else}
498
488
  <CrossIcon width="15px" />
499
- </slot>
489
+ {/if}
500
490
  </button>
501
491
  {/if}
502
492
  </li>
@@ -506,11 +496,6 @@ $: required, form_input?.setCustomValidity(``);
506
496
  style={inputStyle}
507
497
  bind:this={input}
508
498
  bind:value={searchText}
509
- on:mouseup|self|stopPropagation={open_dropdown}
510
- on:keydown|stopPropagation={handle_keydown}
511
- on:focus
512
- on:focus={open_dropdown}
513
- on:input={highlight_matching_options}
514
499
  {id}
515
500
  {disabled}
516
501
  {autocomplete}
@@ -518,41 +503,47 @@ $: required, form_input?.setCustomValidity(``);
518
503
  {pattern}
519
504
  placeholder={selected.length == 0 ? placeholder : null}
520
505
  aria-invalid={invalid ? `true` : null}
521
- on:drop={() => false}
522
- on:blur
523
- on:change
524
- on:click
525
- on:keydown
526
- on:keyup
527
- on:mousedown
528
- on:mouseenter
529
- on:mouseleave
530
- on:touchcancel
531
- on:touchend
532
- on:touchmove
533
- on:touchstart
506
+ ondrop={() => false}
507
+ onmouseup={open_dropdown}
508
+ onkeydown={handle_input_keydown}
509
+ onfocus={handle_input_focus}
510
+ oninput={highlight_matching_options}
511
+ {onblur}
512
+ {onclick}
513
+ {onkeyup}
514
+ {onmousedown}
515
+ {onmouseenter}
516
+ {onmouseleave}
517
+ {ontouchcancel}
518
+ {ontouchend}
519
+ {ontouchmove}
520
+ {ontouchstart}
521
+ {...rest}
534
522
  />
535
523
  <!-- the above on:* lines forward potentially useful DOM events -->
536
- <slot
537
- name="after-input"
538
- {selected}
539
- {disabled}
540
- {invalid}
541
- {id}
542
- {placeholder}
543
- {open}
544
- {required}
545
- />
524
+ {@render afterInput?.({
525
+ selected,
526
+ disabled,
527
+ invalid,
528
+ id,
529
+ placeholder,
530
+ open,
531
+ required,
532
+ })}
546
533
  </ul>
547
534
  {#if loading}
548
- <slot name="spinner">
535
+ {#if spinner}
536
+ {@render spinner()}
537
+ {:else}
549
538
  <CircleSpinner />
550
- </slot>
539
+ {/if}
551
540
  {/if}
552
541
  {#if disabled}
553
- <slot name="disabled-icon">
542
+ {#if disabledIcon}
543
+ {@render disabledIcon()}
544
+ {:else}
554
545
  <DisabledIcon width="14pt" style="margin: 0 2pt;" data-name="disabled-icon" />
555
- </slot>
546
+ {/if}
556
547
  {:else if selected.length > 0}
557
548
  {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
558
549
  <Wiggle bind:wiggle angle={20}>
@@ -566,12 +557,14 @@ $: required, form_input?.setCustomValidity(``);
566
557
  type="button"
567
558
  class="remove remove-all"
568
559
  title={removeAllTitle}
569
- on:mouseup|stopPropagation={remove_all}
570
- on:keydown={if_enter_or_space(remove_all)}
560
+ onclick={remove_all}
561
+ onkeydown={if_enter_or_space(remove_all)}
571
562
  >
572
- <slot name="remove-icon">
563
+ {#if removeIcon}
564
+ {@render removeIcon()}
565
+ {:else}
573
566
  <CrossIcon width="15px" />
574
- </slot>
567
+ {/if}
575
568
  </button>
576
569
  {/if}
577
570
  {/if}
@@ -579,6 +572,7 @@ $: required, form_input?.setCustomValidity(``);
579
572
  <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
580
573
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
581
574
  <ul
575
+ use:portal={{ target_node: outerDiv, ...portal_params }}
582
576
  class:hidden={!open}
583
577
  class="options {ulOptionsClass}"
584
578
  role="listbox"
@@ -588,19 +582,21 @@ $: required, form_input?.setCustomValidity(``);
588
582
  bind:this={ul_options}
589
583
  style={ulOptionsStyle}
590
584
  >
591
- {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as option, idx}
585
+ {#each matchingOptions.slice(0, Math.max(0, maxOptions ?? 0) || Infinity) as optionItem, idx (duplicates ? [key(optionItem), idx] : key(optionItem))}
592
586
  {@const {
593
587
  label,
594
588
  disabled = null,
595
589
  title = null,
596
590
  selectedTitle = null,
597
591
  disabledTitle = defaultDisabledTitle,
598
- } = option instanceof Object ? option : { label: option }}
592
+ } = optionItem instanceof Object ? optionItem : { label: optionItem }}
599
593
  {@const active = activeIndex === idx}
594
+ {@const optionStyle =
595
+ [get_style(optionItem, `option`), liOptionStyle].filter(Boolean).join(` `) ||
596
+ null}
600
597
  <li
601
- on:mousedown|stopPropagation
602
- on:mouseup|stopPropagation={(event) => {
603
- if (!disabled) add(option, event)
598
+ onclick={(event) => {
599
+ if (!disabled) add(optionItem, event)
604
600
  }}
605
601
  title={disabled
606
602
  ? disabledTitle
@@ -609,27 +605,37 @@ $: required, form_input?.setCustomValidity(``);
609
605
  class:active
610
606
  class:disabled
611
607
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
612
- on:mouseover={() => {
608
+ onmouseover={() => {
613
609
  if (!disabled) activeIndex = idx
614
610
  }}
615
- on:focus={() => {
611
+ onfocus={() => {
616
612
  if (!disabled) activeIndex = idx
617
613
  }}
618
- on:mouseout={() => (activeIndex = null)}
619
- on:blur={() => (activeIndex = null)}
620
614
  role="option"
621
615
  aria-selected="false"
622
- style="{get_style(option, `option`)} {liOptionStyle}"
616
+ style={optionStyle}
617
+ onkeydown={(event) => {
618
+ if (!disabled && (event.key === `Enter` || event.code === `Space`)) {
619
+ event.preventDefault()
620
+ add(optionItem, event)
621
+ }
622
+ }}
623
623
  >
624
- <slot name="option" {option} {idx}>
625
- <slot {option} {idx}>
626
- {#if parseLabelsAsHtml}
627
- {@html get_label(option)}
628
- {:else}
629
- {get_label(option)}
630
- {/if}
631
- </slot>
632
- </slot>
624
+ {#if option}
625
+ {@render option({
626
+ option: optionItem,
627
+ idx,
628
+ })}
629
+ {:else if children}
630
+ {@render children({
631
+ option: optionItem,
632
+ idx,
633
+ })}
634
+ {:else if parseLabelsAsHtml}
635
+ {@html get_label(optionItem)}
636
+ {:else}
637
+ {get_label(optionItem)}
638
+ {/if}
633
639
  </li>
634
640
  {/each}
635
641
  {#if searchText}
@@ -646,16 +652,31 @@ $: required, form_input?.setCustomValidity(``);
646
652
  'no-match': noMatchingOptionsMsg,
647
653
  }[msgType]}
648
654
  <li
649
- on:mousedown|stopPropagation
650
- on:mouseup|stopPropagation={(event) => {
651
- if (allowUserOptions) add(searchText, event)
655
+ onclick={(event) => {
656
+ if (msgType === `create` && allowUserOptions) {
657
+ add(searchText as Option, event)
658
+ }
652
659
  }}
653
- title={createOptionMsg}
660
+ onkeydown={(event) => {
661
+ if (
662
+ msgType === `create` &&
663
+ allowUserOptions &&
664
+ (event.key === `Enter` || event.code === `Space`)
665
+ ) {
666
+ event.preventDefault()
667
+ add(searchText as Option, event)
668
+ }
669
+ }}
670
+ title={msgType === `create`
671
+ ? createOptionMsg
672
+ : msgType === `dupe`
673
+ ? duplicateOptionMsg
674
+ : ``}
654
675
  class:active={option_msg_is_active}
655
- on:mouseover={() => (option_msg_is_active = true)}
656
- on:focus={() => (option_msg_is_active = true)}
657
- on:mouseout={() => (option_msg_is_active = false)}
658
- on:blur={() => (option_msg_is_active = false)}
676
+ onmouseover={() => (option_msg_is_active = true)}
677
+ onfocus={() => (option_msg_is_active = true)}
678
+ onmouseout={() => (option_msg_is_active = false)}
679
+ onblur={() => (option_msg_is_active = false)}
659
680
  role="option"
660
681
  aria-selected="false"
661
682
  class="user-msg {liUserMsgClass} {option_msg_is_active
@@ -667,9 +688,11 @@ $: required, form_input?.setCustomValidity(``);
667
688
  'no-match': `default`,
668
689
  }[msgType]}
669
690
  >
670
- <slot name="user-msg" {searchText} {msgType} {msg}>
691
+ {#if userMsg}
692
+ {@render userMsg({ searchText, msgType, msg })}
693
+ {:else}
671
694
  {msg}
672
- </slot>
695
+ {/if}
673
696
  </li>
674
697
  {/if}
675
698
  {/if}
@@ -678,7 +701,7 @@ $: required, form_input?.setCustomValidity(``);
678
701
  </div>
679
702
 
680
703
  <style>
681
- :where(div.multiselect) {
704
+ :is(div.multiselect) {
682
705
  position: relative;
683
706
  align-items: center;
684
707
  display: flex;
@@ -695,27 +718,27 @@ $: required, form_input?.setCustomValidity(``);
695
718
  min-height: var(--sms-min-height, 22pt);
696
719
  margin: var(--sms-margin);
697
720
  }
698
- :where(div.multiselect.open) {
721
+ :is(div.multiselect.open) {
699
722
  /* increase z-index when open to ensure the dropdown of one <MultiSelect />
700
723
  displays above that of another slightly below it on the page */
701
724
  z-index: var(--sms-open-z-index, 4);
702
725
  }
703
- :where(div.multiselect:focus-within) {
726
+ :is(div.multiselect:focus-within) {
704
727
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
705
728
  }
706
- :where(div.multiselect.disabled) {
729
+ :is(div.multiselect.disabled) {
707
730
  background: var(--sms-disabled-bg, lightgray);
708
731
  cursor: not-allowed;
709
732
  }
710
733
 
711
- :where(div.multiselect > ul.selected) {
734
+ :is(div.multiselect > ul.selected) {
712
735
  display: flex;
713
736
  flex: 1;
714
737
  padding: 0;
715
738
  margin: 0;
716
739
  flex-wrap: wrap;
717
740
  }
718
- :where(div.multiselect > ul.selected > li) {
741
+ :is(div.multiselect > ul.selected > li) {
719
742
  align-items: center;
720
743
  border-radius: 3pt;
721
744
  display: flex;
@@ -727,13 +750,13 @@ $: required, form_input?.setCustomValidity(``);
727
750
  padding: var(--sms-selected-li-padding, 1pt 5pt);
728
751
  color: var(--sms-selected-text-color, var(--sms-text-color));
729
752
  }
730
- :where(div.multiselect > ul.selected > li[draggable='true']) {
753
+ :is(div.multiselect > ul.selected > li[draggable='true']) {
731
754
  cursor: grab;
732
755
  }
733
- :where(div.multiselect > ul.selected > li.active) {
756
+ :is(div.multiselect > ul.selected > li.active) {
734
757
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
735
758
  }
736
- :where(div.multiselect button) {
759
+ :is(div.multiselect button) {
737
760
  border-radius: 50%;
738
761
  display: flex;
739
762
  transition: 0.2s;
@@ -742,22 +765,22 @@ $: required, form_input?.setCustomValidity(``);
742
765
  border: none;
743
766
  cursor: pointer;
744
767
  outline: none;
745
- padding: 0;
768
+ padding: 1pt;
746
769
  margin: 0 0 0 3pt; /* CSS reset */
747
770
  }
748
- :where(div.multiselect button.remove-all) {
771
+ :is(div.multiselect button.remove-all) {
749
772
  margin: 0 3pt;
750
773
  }
751
- :where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
774
+ :is(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
752
775
  color: var(--sms-remove-btn-hover-color, lightskyblue);
753
776
  background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
754
777
  }
755
778
 
756
- :where(div.multiselect input) {
779
+ :is(div.multiselect input) {
757
780
  margin: auto 0; /* CSS reset */
758
781
  padding: 0; /* CSS reset */
759
782
  }
760
- :where(div.multiselect > ul.selected > input) {
783
+ :is(div.multiselect > ul.selected > input) {
761
784
  border: none;
762
785
  outline: none;
763
786
  background: none;
@@ -769,13 +792,13 @@ $: required, form_input?.setCustomValidity(``);
769
792
  cursor: inherit; /* needed for disabled state */
770
793
  border-radius: 0; /* reset ul.selected > li */
771
794
  }
772
- /* don't wrap ::placeholder rules in :where() as it seems to be overpowered by browser defaults i.t.o. specificity */
795
+ /* don't wrap ::placeholder rules in :is() as it seems to be overpowered by browser defaults i.t.o. specificity */
773
796
  div.multiselect > ul.selected > input::placeholder {
774
797
  padding-left: 5pt;
775
798
  color: var(--sms-placeholder-color);
776
799
  opacity: var(--sms-placeholder-opacity);
777
800
  }
778
- :where(div.multiselect > input.form-control) {
801
+ :is(div.multiselect > input.form-control) {
779
802
  width: 2em;
780
803
  position: absolute;
781
804
  background: transparent;
@@ -786,14 +809,19 @@ $: required, form_input?.setCustomValidity(``);
786
809
  pointer-events: none;
787
810
  }
788
811
 
789
- :where(div.multiselect > ul.options) {
812
+ ul.options {
790
813
  list-style: none;
814
+ /* top, left, width, position are managed by portal when active */
815
+ /* but provide defaults for non-portaled or initial state */
816
+ position: absolute; /* Default, overridden by portal to fixed when open */
791
817
  top: 100%;
792
818
  left: 0;
793
819
  width: 100%;
794
- position: absolute;
820
+ /* Default z-index if not portaled/overridden by portal */
821
+ z-index: var(--sms-options-z-index, 3);
822
+
795
823
  overflow: auto;
796
- transition: all 0.2s;
824
+ transition: all 0.2s; /* Consider if this transition is desirable with portal positioning */
797
825
  box-sizing: border-box;
798
826
  background: var(--sms-options-bg, white);
799
827
  max-height: var(--sms-options-max-height, 50vh);
@@ -805,35 +833,41 @@ $: required, form_input?.setCustomValidity(``);
805
833
  padding: var(--sms-options-padding);
806
834
  margin: var(--sms-options-margin, inherit);
807
835
  }
808
- :where(div.multiselect > ul.options.hidden) {
836
+ :is(div.multiselect.open) {
837
+ /* increase z-index when open to ensure the dropdown of one <MultiSelect />
838
+ displays above that of another slightly below it on the page */
839
+ /* This z-index is for the div.multiselect itself, portal has its own higher z-index */
840
+ z-index: var(--sms-open-z-index, 4);
841
+ }
842
+ ul.options.hidden {
809
843
  visibility: hidden;
810
844
  opacity: 0;
811
845
  transform: translateY(50px);
812
846
  }
813
- :where(div.multiselect > ul.options > li) {
847
+ ul.options > li {
814
848
  padding: 3pt 2ex;
815
849
  cursor: pointer;
816
850
  scroll-margin: var(--sms-options-scroll-margin, 100px);
817
851
  }
818
- :where(div.multiselect > ul.options .user-msg) {
852
+ ul.options .user-msg {
819
853
  /* block needed so vertical padding applies to span */
820
854
  display: block;
821
855
  padding: 3pt 2ex;
822
856
  }
823
- :where(div.multiselect > ul.options > li.selected) {
857
+ ul.options > li.selected {
824
858
  background: var(--sms-li-selected-bg);
825
859
  color: var(--sms-li-selected-color);
826
860
  }
827
- :where(div.multiselect > ul.options > li.active) {
861
+ ul.options > li.active {
828
862
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
829
863
  }
830
- :where(div.multiselect > ul.options > li.disabled) {
864
+ ul.options > li.disabled {
831
865
  cursor: not-allowed;
832
866
  background: var(--sms-li-disabled-bg, #f5f5f6);
833
867
  color: var(--sms-li-disabled-text, #b8b8b8);
834
868
  }
835
869
 
836
- :where(span.max-select-msg) {
870
+ :is(span.max-select-msg) {
837
871
  padding: 0 3pt;
838
872
  }
839
873
  ::highlight(sms-search-matches) {