svelte-multiselect 11.5.1 → 11.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,7 +74,7 @@ const links = { target: `_blank`, rel: `noreferrer` };
74
74
  transition-duration: var(--code-example-pre-transition-duration, 0.3s);
75
75
  border-radius: var(--code-example-pre-border-radius, 4pt);
76
76
  background-color: var(--code-example-pre-bg, var(--pre-bg));
77
- padding: var(--code-example-pre-padding, 1em);
77
+ padding: var(--code-example-pre-padding, 1ex 1em);
78
78
  }
79
79
  pre.open {
80
80
  visibility: visible;
@@ -26,7 +26,7 @@ loadOptions,
26
26
  // Animation parameters for selected options flip animation
27
27
  selectedFlipParams = { duration: 100 },
28
28
  // Option grouping feature
29
- collapsibleGroups = false, collapsedGroups = $bindable(new Set()), groupSelectAll = false, ungroupedPosition = `first`, groupSortOrder = `none`, searchExpandsCollapsedGroups = false, searchMatchesGroups = false, keyboardExpandsCollapsedGroups = false, stickyGroupHeaders = false, liGroupHeaderClass = ``, liGroupHeaderStyle = null, groupHeader, ongroupToggle, oncollapseAll, onexpandAll, collapseAllGroups = $bindable(), expandAllGroups = $bindable(),
29
+ collapsibleGroups = false, collapsedGroups = $bindable(new Set()), groupSelectAll = false, ungroupedPosition = `first`, groupSortOrder = `none`, searchExpandsCollapsedGroups = false, searchMatchesGroups = false, keyboardExpandsCollapsedGroups = false, stickyGroupHeaders = false, liGroupHeaderClass = ``, liGroupHeaderStyle = null, groupHeader, ongroupToggle, oncollapseAll, onexpandAll, onsearch, onmaxreached, onduplicate, onactivate, collapseAllGroups = $bindable(), expandAllGroups = $bindable(),
30
30
  // Keyboard shortcuts for common actions
31
31
  shortcuts = {}, ...rest } = $props();
32
32
  // Generate unique IDs for ARIA associations (combobox pattern)
@@ -125,6 +125,30 @@ $effect(() => {
125
125
  return () => clearTimeout(timer);
126
126
  }
127
127
  });
128
+ // Debounced onsearch event - fires 150ms after search text stops changing
129
+ let search_debounce_timer = null;
130
+ let search_initialized = false;
131
+ $effect(() => {
132
+ const current_search = searchText;
133
+ // Skip initial mount - only fire on actual user input
134
+ if (!search_initialized) {
135
+ search_initialized = true;
136
+ return;
137
+ }
138
+ if (!onsearch)
139
+ return; // cleanup handles any pending timer
140
+ search_debounce_timer = setTimeout(() => {
141
+ // Optional chaining in case onsearch is removed while timer is pending
142
+ onsearch?.({
143
+ searchText: current_search,
144
+ matchingCount: matchingOptions.length,
145
+ });
146
+ }, 150);
147
+ return () => {
148
+ if (search_debounce_timer)
149
+ clearTimeout(search_debounce_timer);
150
+ };
151
+ });
128
152
  // Internal state for loadOptions feature (null = never loaded)
129
153
  let loaded_options = $state([]);
130
154
  let load_options_has_more = $state(true);
@@ -272,40 +296,44 @@ function sort_selected(items) {
272
296
  }
273
297
  return items;
274
298
  }
275
- if (!loadOptions && !((options?.length ?? 0) > 0)) {
276
- if (allowUserOptions || loading || disabled || allowEmpty) {
277
- options = []; // initializing as array avoids errors when component mounts
278
- }
279
- else {
280
- // error on empty options if user is not allowed to create custom options and loading is false
281
- // and component is not disabled and allowEmpty is false
282
- console.error(`MultiSelect: received no options`);
299
+ untrack(() => {
300
+ if (!loadOptions && !((options?.length ?? 0) > 0)) {
301
+ if (allowUserOptions || loading || disabled || allowEmpty) {
302
+ options = []; // initializing as array avoids errors when component mounts
303
+ }
304
+ else {
305
+ // error on empty options if user is not allowed to create custom options and loading is false
306
+ // and component is not disabled and allowEmpty is false
307
+ console.error(`MultiSelect: received no options`);
308
+ }
283
309
  }
284
- }
310
+ });
285
311
  if (maxSelect !== null && maxSelect < 1) {
286
312
  console.error(`MultiSelect: maxSelect must be null or positive integer, got ${maxSelect}`);
287
313
  }
288
314
  if (!Array.isArray(selected)) {
289
315
  console.error(`MultiSelect: selected prop should always be an array, got ${selected}`);
290
316
  }
291
- if (maxSelect && typeof required === `number` && required > maxSelect) {
292
- console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
293
- }
294
- if (parseLabelsAsHtml && allowUserOptions) {
295
- console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
296
- }
297
- if (sortSelected && selectedOptionsDraggable) {
298
- console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
299
- `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
300
- }
301
- if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
302
- console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
303
- `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
304
- }
305
- if (maxOptions &&
306
- (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
307
- console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
308
- }
317
+ $effect(() => {
318
+ if (maxSelect && typeof required === `number` && required > maxSelect) {
319
+ console.error(`MultiSelect: maxSelect=${maxSelect} < required=${required}, makes it impossible for users to submit a valid form`);
320
+ }
321
+ if (parseLabelsAsHtml && allowUserOptions) {
322
+ console.warn(`MultiSelect: don't combine parseLabelsAsHtml and allowUserOptions. It's susceptible to XSS attacks!`);
323
+ }
324
+ if (sortSelected && selectedOptionsDraggable) {
325
+ console.warn(`MultiSelect: sortSelected and selectedOptionsDraggable should not be combined as any ` +
326
+ `user re-orderings of selected options will be undone by sortSelected on component re-renders.`);
327
+ }
328
+ if (allowUserOptions && !createOptionMsg && createOptionMsg !== null) {
329
+ console.error(`MultiSelect: allowUserOptions=${allowUserOptions} but createOptionMsg=${createOptionMsg} is falsy. ` +
330
+ `This prevents the "Add option" <span> from showing up, resulting in a confusing user experience.`);
331
+ }
332
+ if (maxOptions &&
333
+ (typeof maxOptions != `number` || maxOptions < 0 || maxOptions % 1 != 0)) {
334
+ console.error(`MultiSelect: maxOptions must be undefined or a positive integer, got ${maxOptions}`);
335
+ }
336
+ });
309
337
  let option_msg_is_active = $state(false); // controls active state of <li>{createOptionMsg}</li>
310
338
  let window_width = $state(0);
311
339
  // Check if option matches search text (label or optionally group name)
@@ -367,6 +395,14 @@ function add(option_to_add, event) {
367
395
  option_to_add = Number(option_to_add); // convert to number if possible
368
396
  }
369
397
  const is_duplicate = selected_keys_set.has(key(option_to_add));
398
+ const max_reached = maxSelect !== null && maxSelect !== 1 &&
399
+ selected.length >= maxSelect;
400
+ // Fire events for blocked add attempts
401
+ if (max_reached) {
402
+ onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
403
+ }
404
+ if (is_duplicate && !duplicates)
405
+ onduplicate?.({ option: option_to_add });
370
406
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
371
407
  (duplicates || !is_duplicate)) {
372
408
  if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
@@ -503,6 +539,11 @@ async function handle_arrow_navigation(direction) {
503
539
  }
504
540
  else {
505
541
  const total = navigable_options.length + (has_user_msg ? 1 : 0);
542
+ // Guard against division by zero (can happen if options filtered away before effect resets activeIndex)
543
+ if (total === 0) {
544
+ activeIndex = null;
545
+ return;
546
+ }
506
547
  activeIndex = (activeIndex + direction + total) % total; // +total handles negative mod
507
548
  }
508
549
  // update active state based on new index
@@ -514,6 +555,8 @@ async function handle_arrow_navigation(direction) {
514
555
  await tick();
515
556
  document.querySelector(`ul.options > li.active`)?.scrollIntoViewIfNeeded?.();
516
557
  }
558
+ // Fire onactivate for keyboard navigation only (not mouse hover)
559
+ onactivate?.({ option: activeOption, index: activeIndex });
517
560
  }
518
561
  // handle all keyboard events this component receives
519
562
  async function handle_keydown(event) {
@@ -1359,7 +1402,9 @@ function handle_options_scroll(event) {
1359
1402
  border: 0;
1360
1403
  }
1361
1404
 
1362
- :is(div.multiselect) {
1405
+ /* Use :where() for elements with user-overridable class props (outerDivClass, ulSelectedClass, liSelectedClass)
1406
+ so user-provided classes take precedence. See: https://github.com/janosh/svelte-multiselect/issues/380 */
1407
+ :where(div.multiselect) {
1363
1408
  position: relative;
1364
1409
  align-items: center;
1365
1410
  display: flex;
@@ -1376,27 +1421,27 @@ function handle_options_scroll(event) {
1376
1421
  min-height: var(--sms-min-height, 22pt);
1377
1422
  margin: var(--sms-margin);
1378
1423
  }
1379
- :is(div.multiselect.open) {
1424
+ :where(div.multiselect.open) {
1380
1425
  /* increase z-index when open to ensure the dropdown of one <MultiSelect />
1381
1426
  displays above that of another slightly below it on the page */
1382
1427
  z-index: var(--sms-open-z-index, 4);
1383
1428
  }
1384
- :is(div.multiselect:focus-within) {
1429
+ :where(div.multiselect:focus-within) {
1385
1430
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
1386
1431
  }
1387
- :is(div.multiselect.disabled) {
1432
+ :where(div.multiselect.disabled) {
1388
1433
  background: var(--sms-disabled-bg, light-dark(lightgray, #444));
1389
1434
  cursor: not-allowed;
1390
1435
  }
1391
1436
 
1392
- :is(div.multiselect > ul.selected) {
1437
+ :where(div.multiselect > ul.selected) {
1393
1438
  display: flex;
1394
1439
  flex: 1;
1395
1440
  padding: 0;
1396
1441
  margin: 0;
1397
1442
  flex-wrap: wrap;
1398
1443
  }
1399
- :is(div.multiselect > ul.selected > li) {
1444
+ :where(div.multiselect > ul.selected > li) {
1400
1445
  align-items: center;
1401
1446
  border-radius: 3pt;
1402
1447
  display: flex;
@@ -1411,10 +1456,10 @@ function handle_options_scroll(event) {
1411
1456
  padding: var(--sms-selected-li-padding, 1pt 5pt);
1412
1457
  color: var(--sms-selected-text-color, var(--sms-text-color));
1413
1458
  }
1414
- :is(div.multiselect > ul.selected > li[draggable='true']) {
1459
+ :where(div.multiselect > ul.selected > li[draggable='true']) {
1415
1460
  cursor: grab;
1416
1461
  }
1417
- :is(div.multiselect > ul.selected > li.active) {
1462
+ :where(div.multiselect > ul.selected > li.active) {
1418
1463
  background: var(
1419
1464
  --sms-li-active-bg,
1420
1465
  var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
@@ -1448,7 +1493,7 @@ function handle_options_scroll(event) {
1448
1493
  margin: auto 0; /* CSS reset */
1449
1494
  padding: 0; /* CSS reset */
1450
1495
  }
1451
- :is(div.multiselect > ul.selected > input) {
1496
+ :where(div.multiselect > ul.selected > input) {
1452
1497
  border: none;
1453
1498
  outline: none;
1454
1499
  background: none;
@@ -1462,7 +1507,7 @@ function handle_options_scroll(event) {
1462
1507
  }
1463
1508
 
1464
1509
  /* When options are selected, placeholder is hidden in which case we minimize input width to avoid adding unnecessary width to div.multiselect */
1465
- :is(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
1510
+ :where(div.multiselect > ul.selected > input:not(:placeholder-shown)) {
1466
1511
  min-width: 1px; /* Minimal width to remain interactive */
1467
1512
  }
1468
1513
 
@@ -1483,7 +1528,8 @@ function handle_options_scroll(event) {
1483
1528
  pointer-events: none;
1484
1529
  }
1485
1530
 
1486
- ul.options {
1531
+ /* Use :where() for ul.options elements with class props (ulOptionsClass, liOptionClass, liUserMsgClass) */
1532
+ :where(ul.options) {
1487
1533
  list-style: none;
1488
1534
  /* top, left, width, position are managed by portal when active */
1489
1535
  /* but provide defaults for non-portaled or initial state */
@@ -1511,24 +1557,24 @@ function handle_options_scroll(event) {
1511
1557
  padding: var(--sms-options-padding);
1512
1558
  margin: var(--sms-options-margin, 6pt 0 0 0);
1513
1559
  }
1514
- ul.options.hidden {
1560
+ :where(ul.options.hidden) {
1515
1561
  visibility: hidden;
1516
1562
  opacity: 0;
1517
1563
  transform: translateY(50px);
1518
1564
  pointer-events: none;
1519
1565
  }
1520
- ul.options > li {
1566
+ :where(ul.options > li) {
1521
1567
  padding: 3pt 1ex;
1522
1568
  cursor: pointer;
1523
1569
  scroll-margin: var(--sms-options-scroll-margin, 100px);
1524
1570
  border-left: 3px solid transparent;
1525
1571
  }
1526
- ul.options .user-msg {
1572
+ :where(ul.options .user-msg) {
1527
1573
  /* block needed so vertical padding applies to span */
1528
1574
  display: block;
1529
1575
  padding: 3pt 2ex;
1530
1576
  }
1531
- ul.options > li.selected {
1577
+ :where(ul.options > li.selected) {
1532
1578
  background: var(
1533
1579
  --sms-li-selected-plain-bg,
1534
1580
  light-dark(rgba(0, 123, 255, 0.1), rgba(100, 180, 255, 0.2))
@@ -1538,26 +1584,26 @@ function handle_options_scroll(event) {
1538
1584
  3px solid var(--sms-active-color, cornflowerblue)
1539
1585
  );
1540
1586
  }
1541
- ul.options > li.active {
1587
+ :where(ul.options > li.active) {
1542
1588
  background: var(
1543
1589
  --sms-li-active-bg,
1544
1590
  var(--sms-active-color, light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.15)))
1545
1591
  );
1546
1592
  }
1547
- ul.options > li.disabled {
1593
+ :where(ul.options > li.disabled) {
1548
1594
  cursor: not-allowed;
1549
1595
  background: var(--sms-li-disabled-bg, light-dark(#f5f5f6, #2a2a2a));
1550
1596
  color: var(--sms-li-disabled-text, light-dark(#b8b8b8, #666));
1551
1597
  }
1552
- /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode */
1553
- ul.options > li > input.option-checkbox {
1598
+ /* Checkbox styling for keepSelectedInDropdown='checkboxes' mode - internal, no class prop */
1599
+ :is(ul.options > li > input.option-checkbox) {
1554
1600
  width: 16px;
1555
1601
  height: 16px;
1556
1602
  margin-right: 6px;
1557
1603
  accent-color: var(--sms-active-color, cornflowerblue);
1558
1604
  }
1559
- /* Select all option styling */
1560
- ul.options > li.select-all {
1605
+ /* Select all option styling - has liSelectAllClass prop */
1606
+ :where(ul.options > li.select-all) {
1561
1607
  border-bottom: var(
1562
1608
  --sms-select-all-border-bottom,
1563
1609
  1px solid light-dark(lightgray, #555)
@@ -1567,7 +1613,7 @@ function handle_options_scroll(event) {
1567
1613
  background: var(--sms-select-all-bg, transparent);
1568
1614
  margin-bottom: var(--sms-select-all-margin-bottom, 2pt);
1569
1615
  }
1570
- ul.options > li.select-all:hover {
1616
+ :where(ul.options > li.select-all:hover) {
1571
1617
  background: var(
1572
1618
  --sms-select-all-hover-bg,
1573
1619
  var(
@@ -1579,8 +1625,8 @@ function handle_options_scroll(event) {
1579
1625
  )
1580
1626
  );
1581
1627
  }
1582
- /* Group header styling */
1583
- ul.options > li.group-header {
1628
+ /* Group header styling - has liGroupHeaderClass prop */
1629
+ :where(ul.options > li.group-header) {
1584
1630
  display: flex;
1585
1631
  align-items: center;
1586
1632
  font-weight: var(--sms-group-header-font-weight, 600);
@@ -1593,30 +1639,31 @@ function handle_options_scroll(event) {
1593
1639
  text-transform: var(--sms-group-header-text-transform, uppercase);
1594
1640
  letter-spacing: var(--sms-group-header-letter-spacing, 0.5px);
1595
1641
  }
1596
- ul.options > li.group-header:not(:first-child) {
1642
+ :where(ul.options > li.group-header:not(:first-child)) {
1597
1643
  margin-top: var(--sms-group-header-margin-top, 4pt);
1598
1644
  border-top: var(--sms-group-header-border-top, 1px solid light-dark(#eee, #333));
1599
1645
  }
1600
- ul.options > li.group-header.collapsible {
1646
+ :where(ul.options > li.group-header.collapsible) {
1601
1647
  cursor: pointer;
1602
1648
  }
1603
- ul.options > li.group-header.collapsible:hover {
1649
+ :where(ul.options > li.group-header.collapsible:hover) {
1604
1650
  background: var(
1605
1651
  --sms-group-header-hover-bg,
1606
1652
  light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05))
1607
1653
  );
1608
1654
  }
1609
- ul.options > li.group-header .group-label {
1655
+ /* Internal elements without class props - keep :is() for specificity */
1656
+ :is(ul.options > li.group-header .group-label) {
1610
1657
  flex: 1;
1611
1658
  }
1612
- ul.options > li.group-header .group-count {
1659
+ :is(ul.options > li.group-header .group-count) {
1613
1660
  opacity: 0.6;
1614
1661
  font-size: 0.9em;
1615
1662
  font-weight: normal;
1616
1663
  margin-left: 4pt;
1617
1664
  }
1618
1665
  /* Sticky group headers when enabled */
1619
- ul.options > li.group-header.sticky {
1666
+ :where(ul.options > li.group-header.sticky) {
1620
1667
  position: sticky;
1621
1668
  top: 0;
1622
1669
  z-index: 1;
@@ -1626,17 +1673,20 @@ function handle_options_scroll(event) {
1626
1673
  );
1627
1674
  }
1628
1675
  /* Indent grouped options for visual hierarchy */
1629
- ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more) {
1676
+ :where(
1677
+ ul.options > li:not(.group-header):not(.select-all):not(.user-msg):not(.loading-more)
1678
+ ) {
1630
1679
  padding-left: var(
1631
1680
  --sms-group-item-padding-left,
1632
1681
  var(--sms-group-option-indent, 1.5ex)
1633
1682
  );
1634
1683
  }
1635
- /* Collapse/expand animation for group chevron icon */
1636
- ul.options > li.group-header :global(svg) {
1684
+ /* Collapse/expand animation for group chevron icon - internal, keep :is() for specificity */
1685
+ :is(ul.options > li.group-header) :global(svg) {
1637
1686
  transition: transform var(--sms-group-collapse-duration, 0.15s) ease-out;
1638
1687
  }
1639
- ul.options > li.group-header button.group-select-all {
1688
+ /* Keep :is() for internal buttons without class props */
1689
+ :is(ul.options > li.group-header button.group-select-all) {
1640
1690
  font-size: 0.9em;
1641
1691
  font-weight: normal;
1642
1692
  text-transform: none;
@@ -1649,23 +1699,23 @@ function handle_options_scroll(event) {
1649
1699
  border-radius: 3pt;
1650
1700
  aspect-ratio: auto; /* override global button aspect-ratio: 1 */
1651
1701
  }
1652
- ul.options > li.group-header button.group-select-all:hover {
1702
+ :is(ul.options > li.group-header button.group-select-all:hover) {
1653
1703
  background: var(
1654
1704
  --sms-group-select-all-hover-bg,
1655
1705
  light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1))
1656
1706
  );
1657
1707
  }
1658
- ul.options > li.group-header button.group-select-all.deselect {
1708
+ :is(ul.options > li.group-header button.group-select-all.deselect) {
1659
1709
  color: var(--sms-group-deselect-color, light-dark(#c44, #f77));
1660
1710
  }
1661
- :is(span.max-select-msg) {
1711
+ :where(span.max-select-msg) {
1662
1712
  padding: 0 3pt;
1663
1713
  }
1664
1714
  ::highlight(sms-search-matches) {
1665
1715
  color: light-dark(#1a8870, mediumaquamarine);
1666
1716
  }
1667
- /* Loading more indicator for infinite scrolling */
1668
- ul.options > li.loading-more {
1717
+ /* Loading more indicator for infinite scrolling - internal, no class prop */
1718
+ :is(ul.options > li.loading-more) {
1669
1719
  display: flex;
1670
1720
  justify-content: center;
1671
1721
  align-items: center;