svelte-multiselect 11.6.0 → 11.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import CircleSpinner from './CircleSpinner.svelte';
7
7
  import Icon from './Icon.svelte';
8
8
  import * as utils from './utils';
9
9
  import Wiggle from './Wiggle.svelte';
10
- let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, keepSelectedInDropdown = false, key = (opt) => `${utils.get_label(opt)}`.toLowerCase(), filterFunc = (opt, searchText) => {
10
+ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptionMsg = `Create this option...`, allowUserOptions = false, allowEmpty = false, autocomplete = `off`, autoScroll = true, breakpoint = 800, defaultDisabledTitle = `This option is disabled`, disabled = false, disabledInputTitle = `This input is disabled`, duplicateOptionMsg = `This option is already selected`, duplicates = false, keepSelectedInDropdown = false, key = (opt) => utils.get_option_key(opt), filterFunc = (opt, searchText) => {
11
11
  if (!searchText)
12
12
  return true;
13
13
  const label = `${utils.get_label(opt)}`;
@@ -243,7 +243,16 @@ let selected_keys = $derived(selected.map(key));
243
243
  let selected_labels = $derived(selected.map(utils.get_label));
244
244
  // Sets for O(1) lookups (used in template, has_user_msg, group_header_state, batch operations)
245
245
  let selected_keys_set = $derived(new Set(selected_keys));
246
- let selected_labels_set = $derived(new Set(selected_labels));
246
+ // String-normalized for consistent comparison (numeric labels like 123 match "123")
247
+ let selected_labels_set = $derived(new Set(selected_labels.map((lbl) => `${lbl}`)));
248
+ // Lowercase labels set for case-insensitive duplicate detection
249
+ let selected_labels_lower_set = $derived(duplicates === `case-insensitive`
250
+ ? new Set(selected_labels.map((lbl) => `${lbl}`.toLowerCase()))
251
+ : null);
252
+ // Helper to check if a label is already selected (respects case-insensitive mode)
253
+ const is_label_selected = (label) => selected_labels_lower_set
254
+ ? selected_labels_lower_set.has(label.toLowerCase())
255
+ : selected_labels_set.has(label);
247
256
  // Memoized Set of disabled option keys for O(1) lookups in large option sets
248
257
  let disabled_option_keys = $derived(new Set(effective_options
249
258
  .filter((opt) => utils.is_object(opt) && opt.disabled)
@@ -482,18 +491,26 @@ function add(option_to_add, event) {
482
491
  typeof selected_labels[0] === `number`) {
483
492
  option_to_add = Number(option_to_add); // convert to number if possible
484
493
  }
485
- const is_duplicate = selected_keys_set.has(key(option_to_add));
494
+ // Check for duplicates by key, plus label check for user-created options
495
+ // For duplicates=false (default), label check only applies to user-typed text
496
+ // For duplicates='case-insensitive', label check applies to all options
497
+ // Use key comparison instead of reference equality (more robust with Svelte proxies)
498
+ const option_key = key(option_to_add);
499
+ const is_from_options = effective_options.some((opt) => key(opt) === option_key);
500
+ const check_label = duplicates === `case-insensitive` || !is_from_options;
501
+ const is_duplicate = selected_keys_set.has(key(option_to_add)) ||
502
+ (check_label && is_label_selected(`${utils.get_label(option_to_add)}`));
486
503
  const max_reached = maxSelect !== null && maxSelect !== 1 &&
487
504
  selected.length >= maxSelect;
488
505
  // Fire events for blocked add attempts
489
506
  if (max_reached) {
490
507
  onmaxreached?.({ selected, maxSelect, attemptedOption: option_to_add });
491
508
  }
492
- if (is_duplicate && !duplicates)
509
+ if (is_duplicate && duplicates !== true)
493
510
  onduplicate?.({ option: option_to_add });
494
511
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
495
- (duplicates || !is_duplicate)) {
496
- if (!effective_options.includes(option_to_add) && // first check if we find option in the options list
512
+ (duplicates === true || !is_duplicate)) {
513
+ if (!is_from_options && // first check if we find option in the options list
497
514
  // this has the side-effect of not allowing to user to add the same
498
515
  // custom option twice in append mode
499
516
  [true, `append`].includes(allowUserOptions) &&
@@ -606,7 +623,7 @@ function handle_dropdown_after_select(event) {
606
623
  // Check if a user message (create option, duplicate warning, no match) is visible
607
624
  const has_user_msg = $derived(searchText.length > 0 &&
608
625
  Boolean((allowUserOptions && createOptionMsg) ||
609
- (!duplicates && selected_labels_set.has(searchText)) ||
626
+ (duplicates !== true && is_label_selected(searchText)) ||
610
627
  (navigable_options.length === 0 && noMatchingOptionsMsg)));
611
628
  // Handle arrow key navigation through options (uses navigable_options to skip collapsed groups)
612
629
  async function handle_arrow_navigation(direction) {
@@ -827,7 +844,7 @@ function toggle_group_selection(group_opts, group_collapsed, all_selected, event
827
844
  }
828
845
  }
829
846
  // O(1) lookup using pre-computed Set instead of O(n) array.includes()
830
- const is_selected = (label) => selected_labels_set.has(label);
847
+ const is_selected = (label) => selected_labels_set.has(`${label}`);
831
848
  const if_enter_or_space = (handler) => (event) => {
832
849
  if (event.key === `Enter` || event.code === `Space`) {
833
850
  event.preventDefault();
@@ -1414,8 +1431,7 @@ function handle_options_scroll(event) {
1414
1431
  {/if}
1415
1432
  {/each}
1416
1433
  {#if searchText}
1417
- {@const text_input_is_duplicate = selected_labels.includes(searchText)}
1418
- {@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
1434
+ {@const is_dupe = duplicates !== true && is_label_selected(searchText) && `dupe`}
1419
1435
  {@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
1420
1436
  {@const no_match =
1421
1437
  Boolean(navigable_options?.length === 0 && noMatchingOptionsMsg) &&
package/dist/Nav.svelte CHANGED
@@ -1,13 +1,14 @@
1
1
  <script lang="ts">import { click_outside, tooltip } from './attachments';
2
2
  import Icon from './Icon.svelte';
3
3
  import { get_uuid } from './utils';
4
- let { routes = [], children, item, link, menu_props, link_props, page, labels, tooltips, tooltip_options, breakpoint = 767, onnavigate, onopen, onclose, ...rest } = $props();
4
+ let { routes = [], children, item, link, menu_props, link_props, page, labels, tooltips, tooltip_options, breakpoint = 767, dropdown_cooldown = 150, onnavigate, onopen, onclose, ...rest } = $props();
5
5
  let is_open = $state(false);
6
6
  let hovered_dropdown = $state(null);
7
7
  let pinned_dropdown = $state(null);
8
8
  let focused_item_index = $state(-1);
9
9
  let is_touch_device = $state(false);
10
10
  let is_mobile = $state(false);
11
+ let hide_timeout = null;
11
12
  const panel_id = `nav-menu-${get_uuid()}`;
12
13
  // Track previous is_open state for callbacks
13
14
  let prev_is_open = $state(false);
@@ -34,7 +35,13 @@ $effect(() => {
34
35
  }
35
36
  prev_is_open = is_open;
36
37
  });
38
+ $effect(() => () => {
39
+ if (hide_timeout)
40
+ clearTimeout(hide_timeout);
41
+ }); // cleanup on destroy
37
42
  function close_menus() {
43
+ if (hide_timeout)
44
+ clearTimeout(hide_timeout);
38
45
  is_open = false;
39
46
  hovered_dropdown = null;
40
47
  pinned_dropdown = null;
@@ -45,7 +52,6 @@ function toggle_dropdown(href, focus_first = false) {
45
52
  pinned_dropdown = is_opening ? href : null;
46
53
  hovered_dropdown = is_opening ? href : null;
47
54
  focused_item_index = is_opening && focus_first ? 0 : -1;
48
- // Focus management for keyboard users
49
55
  if (is_opening && focus_first) {
50
56
  setTimeout(() => {
51
57
  document
@@ -54,19 +60,23 @@ function toggle_dropdown(href, focus_first = false) {
54
60
  }, 0);
55
61
  }
56
62
  }
57
- function handle_dropdown_mouseenter(href) {
58
- if (is_touch_device)
63
+ function open_dropdown(href, from_mouse = false) {
64
+ if (from_mouse && is_touch_device)
59
65
  return;
60
- const is_this_pinned = pinned_dropdown === href;
61
- if (pinned_dropdown && !is_this_pinned)
66
+ if (hide_timeout)
67
+ clearTimeout(hide_timeout);
68
+ if (pinned_dropdown && pinned_dropdown !== href)
62
69
  pinned_dropdown = null;
63
70
  hovered_dropdown = href;
64
71
  }
65
- function handle_dropdown_focusin(href) {
66
- const is_this_pinned = pinned_dropdown === href;
67
- if (pinned_dropdown && !is_this_pinned)
68
- pinned_dropdown = null;
69
- hovered_dropdown = href;
72
+ function schedule_hide(href, is_pinned) {
73
+ if (is_touch_device || is_pinned)
74
+ return;
75
+ clearTimeout(hide_timeout);
76
+ hide_timeout = setTimeout(() => {
77
+ if (hovered_dropdown === href)
78
+ hovered_dropdown = null;
79
+ }, dropdown_cooldown);
70
80
  }
71
81
  function onkeydown(event) {
72
82
  if (event.key === `Escape`)
@@ -277,11 +287,9 @@ function get_external_attrs(route) {
277
287
  data-href={parsed_route.href}
278
288
  role="group"
279
289
  aria-current={child_is_active ? `true` : undefined}
280
- onmouseenter={() => handle_dropdown_mouseenter(parsed_route.href)}
281
- onmouseleave={() => {
282
- if (!is_touch_device && !is_pinned) hovered_dropdown = null
283
- }}
284
- onfocusin={() => handle_dropdown_focusin(parsed_route.href)}
290
+ onmouseenter={() => open_dropdown(parsed_route.href, true)}
291
+ onmouseleave={() => schedule_hide(parsed_route.href, is_pinned)}
292
+ onfocusin={() => open_dropdown(parsed_route.href)}
285
293
  onfocusout={(event) => {
286
294
  const next = event.relatedTarget as Node | null
287
295
  if (!next || !(event.currentTarget as HTMLElement).contains(next)) {
@@ -339,9 +347,8 @@ function get_external_attrs(route) {
339
347
  class:visible={dropdown_open}
340
348
  role="menu"
341
349
  tabindex="-1"
342
- onmouseenter={() => {
343
- if (!is_touch_device) hovered_dropdown = parsed_route.href
344
- }}
350
+ onmouseenter={() => open_dropdown(parsed_route.href, true)}
351
+ onmouseleave={() => schedule_hide(parsed_route.href, is_pinned)}
345
352
  >
346
353
  {#each filtered_sub_routes as child_href (child_href)}
347
354
  {@const child_formatted = format_label(child_href, true)}
@@ -406,7 +413,7 @@ function get_external_attrs(route) {
406
413
  nav {
407
414
  position: relative;
408
415
  margin: -0.75em auto 1.25em;
409
- --nav-border-radius: 6pt;
416
+ --nav-border-radius: 3pt;
410
417
  --nav-surface-bg: light-dark(#fafafa, #1a1a1a);
411
418
  --nav-surface-border: light-dark(
412
419
  rgba(128, 128, 128, 0.25),
@@ -437,7 +444,7 @@ function get_external_attrs(route) {
437
444
  }
438
445
  .menu > span > a {
439
446
  line-height: 1.3;
440
- padding: var(--nav-item-padding);
447
+ padding: var(--nav-item-padding, 1pt 4pt);
441
448
  text-decoration: none;
442
449
  color: inherit;
443
450
  }
@@ -497,7 +504,7 @@ function get_external_attrs(route) {
497
504
  }
498
505
  .dropdown > div:first-child > a, .dropdown > div:first-child > span {
499
506
  line-height: 1.3;
500
- padding: var(--nav-item-padding);
507
+ padding: var(--nav-item-padding, 1pt 4pt);
501
508
  text-decoration: none;
502
509
  color: inherit;
503
510
  border-radius: var(--nav-border-radius) 0 0 var(--nav-border-radius);
@@ -534,9 +541,12 @@ function get_external_attrs(route) {
534
541
  .dropdown > div:last-child {
535
542
  position: absolute;
536
543
  top: 100%;
537
- left: 0;
544
+ left: var(--nav-dropdown-left, 0);
545
+ right: var(--nav-dropdown-right, auto);
538
546
  margin: var(--nav-dropdown-margin, 2pt) 0 0 0;
539
- min-width: max-content;
547
+ min-width: var(--nav-dropdown-min-width, 100%); /* at least as wide as parent */
548
+ max-width: var(--nav-dropdown-max-width, none);
549
+ width: var(--nav-dropdown-width, max-content); /* grow wider if content needs it */
540
550
  background-color: var(--nav-dropdown-bg, var(--nav-surface-bg));
541
551
  border: 1px solid var(--nav-dropdown-border-color, var(--nav-surface-border));
542
552
  border-radius: var(--nav-border-radius, 6pt);
@@ -30,6 +30,7 @@ type $$ComponentProps = {
30
30
  tooltips?: Record<string, string | Omit<TooltipOptions, `disabled`>>;
31
31
  tooltip_options?: Omit<TooltipOptions, `content`>;
32
32
  breakpoint?: number;
33
+ dropdown_cooldown?: number;
33
34
  onnavigate?: (data: {
34
35
  href: string;
35
36
  event: MouseEvent;
@@ -64,6 +64,7 @@ export interface TooltipOptions {
64
64
  show_arrow?: boolean;
65
65
  offset?: number;
66
66
  allow_html?: boolean;
67
+ sanitize_html?: (html: string) => string;
67
68
  }
68
69
  export declare const tooltip: (options?: TooltipOptions) => Attachment;
69
70
  export type ClickOutsideConfig<T extends HTMLElement> = {
@@ -422,16 +422,141 @@ export const tooltip = (options = {}) => (node) => {
422
422
  content = new_content;
423
423
  // Only update tooltip if this element owns it
424
424
  if (current_tooltip?._owner === element) {
425
- if (options.allow_html !== false) {
426
- current_tooltip.innerHTML = content.replace(/\r/g, `<br/>`);
427
- }
428
- else {
429
- current_tooltip.textContent = content;
425
+ const content_el = current_tooltip.querySelector(`.tooltip-content`);
426
+ if (content_el) {
427
+ if (options.allow_html !== false) {
428
+ let html = content.replace(/\r/g, `<br/>`);
429
+ if (options.sanitize_html)
430
+ html = options.sanitize_html(html);
431
+ content_el.innerHTML = html;
432
+ }
433
+ else {
434
+ content_el.textContent = content;
435
+ }
436
+ // Re-run sizing/positioning after content change
437
+ resize_and_position_tooltip(current_tooltip, element);
430
438
  }
431
439
  }
432
440
  }
433
441
  });
434
442
  observer.observe(element, { attributes: true, attributeFilter: tooltip_attrs });
443
+ // Shrink tooltip to fit content, then position for correct centering
444
+ function resize_and_position_tooltip(tooltip_el, trigger) {
445
+ // Reset width to allow natural sizing before measuring
446
+ tooltip_el.style.width = ``;
447
+ requestAnimationFrame(() => {
448
+ if (!document.body.contains(tooltip_el))
449
+ return;
450
+ const computed = getComputedStyle(tooltip_el);
451
+ const padding_h = parseFloat(computed.paddingLeft) +
452
+ parseFloat(computed.paddingRight);
453
+ const border_h = parseFloat(computed.borderLeftWidth) +
454
+ parseFloat(computed.borderRightWidth);
455
+ // Handle max-width: none as unbounded, fallback to 280 only for invalid values
456
+ const max_width_raw = computed.maxWidth;
457
+ const max_width_parsed = parseFloat(max_width_raw);
458
+ const max_width = max_width_raw === `none`
459
+ ? Infinity
460
+ : Number.isFinite(max_width_parsed)
461
+ ? max_width_parsed
462
+ : 280;
463
+ // With border-box, width includes padding+border; with content-box, subtract them
464
+ const box_adjust = computed.boxSizing === `border-box` ? 0 : padding_h + border_h;
465
+ const style = tooltip_el.style;
466
+ const placement = tooltip_el.getAttribute(`data-placement`) || `bottom`;
467
+ // Save styles, measure single-line width with wrapping disabled
468
+ const saved = {
469
+ maxWidth: style.maxWidth,
470
+ wordWrap: style.wordWrap,
471
+ textWrap: style.textWrap,
472
+ whiteSpace: style.whiteSpace,
473
+ };
474
+ Object.assign(style, {
475
+ maxWidth: `none`,
476
+ wordWrap: `normal`,
477
+ textWrap: `nowrap`,
478
+ whiteSpace: `nowrap`,
479
+ width: `auto`,
480
+ });
481
+ const single_line_width = tooltip_el.offsetWidth;
482
+ Object.assign(style, saved);
483
+ // Convert offsetWidth to the value needed for style.width
484
+ const content_width = single_line_width - box_adjust;
485
+ if (content_width <= max_width) {
486
+ // Single-line: set exact width and prevent wrapping
487
+ style.width = `${Math.max(0, content_width)}px`;
488
+ style.textWrap = `nowrap`;
489
+ }
490
+ else {
491
+ // Multi-line: binary search for minimum width that doesn't add line breaks
492
+ style.width = ``;
493
+ const baseline_height = tooltip_el.offsetHeight;
494
+ const initial_width = tooltip_el.offsetWidth;
495
+ // Get min-content (longest word) as lower bound
496
+ Object.assign(style, {
497
+ maxWidth: `none`,
498
+ wordWrap: `normal`,
499
+ width: `min-content`,
500
+ });
501
+ const min_width = tooltip_el.offsetWidth;
502
+ Object.assign(style, {
503
+ maxWidth: saved.maxWidth,
504
+ wordWrap: saved.wordWrap,
505
+ width: `${initial_width - box_adjust}px`,
506
+ });
507
+ // If longest word exceeds wrapped width, use min_width (can't shrink further)
508
+ if (min_width >= initial_width) {
509
+ style.width = `${min_width - box_adjust}px`;
510
+ }
511
+ else {
512
+ // Binary search for minimum width that maintains baseline height
513
+ // Work in offsetWidth units, convert to style.width only when setting
514
+ let low = min_width, high = initial_width, best = initial_width;
515
+ while (high - low > 1) {
516
+ const mid = Math.floor((low + high) / 2);
517
+ style.width = `${mid - box_adjust}px`;
518
+ if (tooltip_el.offsetHeight > baseline_height)
519
+ low = mid;
520
+ else {
521
+ best = mid;
522
+ high = mid;
523
+ }
524
+ }
525
+ style.width = `${best - box_adjust}px`;
526
+ }
527
+ }
528
+ // Position tooltip after width adjustment so centering uses final dimensions
529
+ const rect = trigger.getBoundingClientRect();
530
+ const tooltip_rect = tooltip_el.getBoundingClientRect();
531
+ const margin = options.offset ?? 12;
532
+ let top = 0, left = 0;
533
+ if (placement === `top`) {
534
+ top = rect.top - tooltip_rect.height - margin;
535
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
536
+ }
537
+ else if (placement === `left`) {
538
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
539
+ left = rect.left - tooltip_rect.width - margin;
540
+ }
541
+ else if (placement === `right`) {
542
+ top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
543
+ left = rect.right + margin;
544
+ }
545
+ else { // bottom
546
+ top = rect.bottom + margin;
547
+ left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
548
+ }
549
+ // Keep in viewport
550
+ const viewport_width = globalThis.innerWidth;
551
+ const viewport_height = globalThis.innerHeight;
552
+ left = Math.max(8, Math.min(left, viewport_width - tooltip_rect.width - 8));
553
+ top = Math.max(8, Math.min(top, viewport_height - tooltip_rect.height - 8));
554
+ style.left = `${left + globalThis.scrollX}px`;
555
+ style.top = `${top + globalThis.scrollY}px`;
556
+ style.opacity =
557
+ getComputedStyle(trigger).getPropertyValue(`--tooltip-opacity`).trim() || `1`;
558
+ });
559
+ }
435
560
  function show_tooltip() {
436
561
  // Skip tooltip on touch input when 'touch-devices' option is set
437
562
  if (options.disabled === `touch-devices` && last_pointer_type === `touch`)
@@ -449,7 +574,7 @@ export const tooltip = (options = {}) => (node) => {
449
574
  element.setAttribute(`aria-describedby`, tooltip_id);
450
575
  // Apply base styles
451
576
  tooltip_el.style.cssText = `
452
- position: absolute; z-index: 9999; opacity: 0;
577
+ position: absolute; z-index: 9999; opacity: 0; display: inline-block;
453
578
  background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
454
579
  padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
455
580
  max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; text-wrap: balance; pointer-events: none;
@@ -465,13 +590,20 @@ export const tooltip = (options = {}) => (node) => {
465
590
  tooltip_el.style.setProperty(property, value);
466
591
  });
467
592
  }
593
+ // Wrap content in a span for reactive content updates
594
+ const content_span = document.createElement(`span`);
595
+ content_span.className = `tooltip-content`;
468
596
  // Security: allow_html defaults to true; set to false for plain text rendering
469
597
  if (options.allow_html !== false) {
470
- tooltip_el.innerHTML = content?.replace(/\r/g, `<br/>`) ?? ``;
598
+ let html = content?.replace(/\r/g, `<br/>`) ?? ``;
599
+ if (options.sanitize_html)
600
+ html = options.sanitize_html(html);
601
+ content_span.innerHTML = html;
471
602
  }
472
603
  else {
473
- tooltip_el.textContent = content ?? ``;
604
+ content_span.textContent = content ?? ``;
474
605
  }
606
+ tooltip_el.appendChild(content_span);
475
607
  // Mirror CSS custom properties from the trigger node onto the tooltip element
476
608
  const trigger_styles = getComputedStyle(element);
477
609
  [
@@ -578,34 +710,7 @@ export const tooltip = (options = {}) => (node) => {
578
710
  append_border_arrow();
579
711
  tooltip_el.appendChild(arrow);
580
712
  }
581
- // Position tooltip
582
- const rect = element.getBoundingClientRect();
583
- const tooltip_rect = tooltip_el.getBoundingClientRect();
584
- const margin = options.offset ?? 12;
585
- let top = 0, left = 0;
586
- if (placement === `top`) {
587
- top = rect.top - tooltip_rect.height - margin;
588
- left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
589
- }
590
- else if (placement === `left`) {
591
- top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
592
- left = rect.left - tooltip_rect.width - margin;
593
- }
594
- else if (placement === `right`) {
595
- top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
596
- left = rect.right + margin;
597
- }
598
- else { // bottom
599
- top = rect.bottom + margin;
600
- left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
601
- }
602
- // Keep in viewport
603
- left = Math.max(8, Math.min(left, globalThis.innerWidth - tooltip_rect.width - 8));
604
- top = Math.max(8, Math.min(top, globalThis.innerHeight - tooltip_rect.height - 8));
605
- tooltip_el.style.left = `${left + globalThis.scrollX}px`;
606
- tooltip_el.style.top = `${top + globalThis.scrollY}px`;
607
- const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
608
- tooltip_el.style.opacity = custom_opacity || `1`;
713
+ resize_and_position_tooltip(tooltip_el, element);
609
714
  current_tooltip = Object.assign(tooltip_el, { _owner: element });
610
715
  }, options.delay || 100);
611
716
  }
@@ -1,12 +1,6 @@
1
1
  // Starry-night highlighter for mdsvex
2
- import { createStarryNight } from '@wooorm/starry-night';
3
- import source_css from '@wooorm/starry-night/source.css';
4
- import source_js from '@wooorm/starry-night/source.js';
5
- import source_json from '@wooorm/starry-night/source.json';
6
- import source_shell from '@wooorm/starry-night/source.shell';
2
+ import { common, createStarryNight } from '@wooorm/starry-night';
7
3
  import source_svelte from '@wooorm/starry-night/source.svelte';
8
- import source_ts from '@wooorm/starry-night/source.ts';
9
- import text_html_basic from '@wooorm/starry-night/text.html.basic';
10
4
  // Escape HTML special characters in text content (not for attribute values)
11
5
  const escape_html_text = (str) => str.replace(/&/g, `&amp;`).replace(/</g, `&lt;`).replace(/>/g, `&gt;`);
12
6
  // Convert HAST to HTML string (simplified - only handles what starry-night outputs)
@@ -14,43 +8,22 @@ export const hast_to_html = (node) => {
14
8
  if (node.type === `text`)
15
9
  return escape_html_text(node.value);
16
10
  if (node.type === `root`)
17
- return node.children?.map(hast_to_html).join(``) ?? ``;
11
+ return node.children.map(hast_to_html).join(``);
18
12
  const { tagName, properties, children } = node;
19
13
  const cls = properties?.className?.join(` `);
20
14
  const attrs = cls ? ` class="${cls}"` : ``;
21
15
  const inner = children?.map(hast_to_html).join(``) ?? ``;
22
16
  return `<${tagName}${attrs}>${inner}</${tagName}>`;
23
17
  };
24
- // Shared starry-night instance (grammars loaded once at build time)
25
- export const starry_night = await createStarryNight([
26
- source_svelte,
27
- source_js,
28
- source_ts,
29
- source_css,
30
- source_json,
31
- source_shell,
32
- text_html_basic,
33
- ]);
34
- // Map code fence language to starry-night grammar scope
35
- export const LANG_TO_SCOPE = {
36
- svelte: `source.svelte`,
37
- html: `text.html.basic`,
38
- ts: `source.ts`,
39
- typescript: `source.ts`,
40
- js: `source.js`,
41
- javascript: `source.js`,
42
- css: `source.css`,
43
- json: `source.json`,
44
- shell: `source.shell`,
45
- bash: `source.shell`,
46
- sh: `source.shell`,
47
- };
48
18
  // Escape characters that would be interpreted as Svelte template syntax
49
19
  const escape_svelte = (html) => html.replace(/\{/g, `&#123;`).replace(/\}/g, `&#125;`);
20
+ // Shared starry-night instance (grammars loaded once at build time)
21
+ // Uses common bundle (34 grammars) + Svelte
22
+ export const starry_night = await createStarryNight([...common, source_svelte]);
50
23
  // mdsvex highlighter function
51
24
  export function starry_night_highlighter(code, lang) {
52
25
  const lang_key = lang?.toLowerCase();
53
- const scope = lang_key ? LANG_TO_SCOPE[lang_key] : undefined;
26
+ const scope = lang_key ? starry_night.flagToScope(lang_key) : undefined;
54
27
  if (!scope) {
55
28
  // Return escaped code if language not supported
56
29
  const escaped = escape_svelte(escape_html_text(code));
@@ -1,4 +1,4 @@
1
- export { starry_night_highlighter } from './highlighter.js';
1
+ export { hast_to_html, starry_night, starry_night_highlighter } from './highlighter.js';
2
2
  export { default as mdsvex_transform, EXAMPLE_COMPONENT_PREFIX, EXAMPLE_MODULE_PREFIX, } from './mdsvex-transform.js';
3
3
  export { default as vite_plugin } from './vite-plugin.js';
4
4
  import { sveltePreprocess as _sveltePreprocess } from 'svelte-preprocess';
@@ -1,6 +1,6 @@
1
1
  // Live examples - transforms ```svelte example code blocks into rendered components
2
2
  // with syntax highlighting and live preview
3
- export { starry_night_highlighter } from './highlighter.js';
3
+ export { hast_to_html, starry_night, starry_night_highlighter } from './highlighter.js';
4
4
  export { default as mdsvex_transform, EXAMPLE_COMPONENT_PREFIX, EXAMPLE_MODULE_PREFIX, } from './mdsvex-transform.js';
5
5
  export { default as vite_plugin } from './vite-plugin.js';
6
6
  import { sveltePreprocess as _sveltePreprocess } from 'svelte-preprocess';
@@ -1,7 +1,7 @@
1
1
  // Remark plugin - transforms ```svelte example code blocks into rendered components
2
2
  import { Buffer } from 'node:buffer';
3
3
  import path from 'node:path';
4
- import { hast_to_html, LANG_TO_SCOPE, starry_night } from './highlighter.js';
4
+ import { hast_to_html, starry_night } from './highlighter.js';
5
5
  // Base64 encode to prevent preprocessors from modifying the content
6
6
  const to_base64 = (src) => Buffer.from(src, `utf-8`).toString(`base64`);
7
7
  // Escape backticks and template literal syntax for embedding in template literals
@@ -19,8 +19,6 @@ export const EXAMPLE_MODULE_PREFIX = `___live_example___`;
19
19
  export const EXAMPLE_COMPONENT_PREFIX = `LiveExample___`;
20
20
  // Languages that render as live Svelte components (O(1) lookup)
21
21
  const LIVE_LANGUAGES = new Set([`svelte`, `html`]);
22
- // All languages that support the `example` meta (O(1) lookup)
23
- const EXAMPLE_LANGUAGES = new Set(Object.keys(LANG_TO_SCOPE));
24
22
  // Simple tree traversal - finds all nodes of a given type
25
23
  const visit = (tree, type, callback) => {
26
24
  const walk = (nodes) => {
@@ -64,7 +62,7 @@ function remark(options = {}) {
64
62
  };
65
63
  const { csr, example, Wrapper } = meta;
66
64
  // find code blocks with `example` meta in supported languages
67
- if (example && node.lang && EXAMPLE_LANGUAGES.has(node.lang)) {
65
+ if (example && node.lang && starry_night.flagToScope(node.lang)) {
68
66
  const is_live = LIVE_LANGUAGES.has(node.lang);
69
67
  const wrapper_alias = is_live ? get_wrapper_alias(Wrapper ?? DEFAULT_WRAPPER) : ``;
70
68
  const value = create_example_component(node.value || ``, meta, is_live ? examples.length : -1, // -1 for code-only (no component import needed)
@@ -144,7 +142,10 @@ function format_code(code, meta) {
144
142
  }
145
143
  function create_example_component(value, meta, index, lang, is_live, wrapper_alias) {
146
144
  const code = format_code(value, meta);
147
- const tree = starry_night.highlight(code, LANG_TO_SCOPE[lang]);
145
+ const scope = starry_night.flagToScope(lang);
146
+ if (!scope)
147
+ throw new Error(`Unsupported language: ${lang}`);
148
+ const tree = starry_night.highlight(code, scope);
148
149
  // Convert newlines to &#10; to prevent bundlers from stripping whitespace
149
150
  const highlighted = hast_to_html(tree).replace(/\n/g, `&#10;`);
150
151
  // Code-only examples (ts, js, css, etc.) - just render highlighted code block
package/dist/types.d.ts CHANGED
@@ -160,7 +160,7 @@ export interface MultiSelectProps<T extends Option = Option> extends MultiSelect
160
160
  disabled?: boolean;
161
161
  disabledInputTitle?: string;
162
162
  duplicateOptionMsg?: string;
163
- duplicates?: boolean;
163
+ duplicates?: boolean | `case-insensitive`;
164
164
  keepSelectedInDropdown?: false | `plain` | `checkboxes`;
165
165
  key?: (opt: T) => unknown;
166
166
  filterFunc?: (opt: T, searchText: string) => boolean;
package/dist/utils.d.ts CHANGED
@@ -5,5 +5,6 @@ export declare const has_group: <T extends Option>(opt: T) => opt is T & {
5
5
  group: string;
6
6
  };
7
7
  export declare const get_label: (opt: Option) => string | number;
8
+ export declare const get_option_key: (opt: Option) => unknown;
8
9
  export declare function get_style(option: Option, key?: `selected` | `option` | null | undefined): string;
9
10
  export declare function fuzzy_match(search_text: string, target_text: string): boolean;
package/dist/utils.js CHANGED
@@ -25,6 +25,10 @@ export const get_label = (opt) => {
25
25
  }
26
26
  return `${opt}`;
27
27
  };
28
+ // Generate a unique key for an option, preserving value identity
29
+ // For object options: uses value if defined, otherwise label (no case normalization)
30
+ // For primitives: the primitive itself
31
+ export const get_option_key = (opt) => is_object(opt) ? opt.value ?? get_label(opt) : opt;
28
32
  // This function is used extract CSS strings from a {selected, option} style
29
33
  // object to be used in the style attribute of the option.
30
34
  // If the style is a string, it will be returned as is
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "homepage": "https://janosh.github.io/svelte-multiselect",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "11.6.0",
8
+ "version": "11.6.1",
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "dev": "vite dev",