svelte-multiselect 11.5.2 → 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.
@@ -2,7 +2,7 @@ import type { MultiSelectProps } from './types';
2
2
  declare function $$render<Option extends import('./types').Option>(): {
3
3
  props: MultiSelectProps<Option>;
4
4
  exports: {};
5
- bindings: "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "maxSelect" | "options" | "outerDiv" | "searchText" | "collapsedGroups" | "collapseAllGroups" | "expandAllGroups";
5
+ bindings: "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "maxSelect" | "options" | "outerDiv" | "searchText" | "collapsedGroups" | "collapseAllGroups" | "expandAllGroups" | "undo" | "redo" | "canUndo" | "canRedo";
6
6
  slots: {};
7
7
  events: {};
8
8
  };
@@ -10,7 +10,7 @@ declare class __sveltets_Render<Option extends import('./types').Option> {
10
10
  props(): ReturnType<typeof $$render<Option>>['props'];
11
11
  events(): ReturnType<typeof $$render<Option>>['events'];
12
12
  slots(): ReturnType<typeof $$render<Option>>['slots'];
13
- bindings(): "input" | "invalid" | "value" | "selected" | "open" | "activeIndex" | "activeOption" | "form_input" | "matchingOptions" | "maxSelect" | "options" | "outerDiv" | "searchText" | "collapsedGroups" | "collapseAllGroups" | "expandAllGroups";
13
+ bindings(): "value" | "selected" | "invalid" | "open" | "activeIndex" | "activeOption" | "form_input" | "input" | "matchingOptions" | "maxSelect" | "options" | "outerDiv" | "searchText" | "collapsedGroups" | "collapseAllGroups" | "expandAllGroups" | "undo" | "redo" | "canUndo" | "canRedo";
14
14
  exports(): {};
15
15
  }
16
16
  interface $$IsomorphicComponent {
package/dist/Nav.svelte CHANGED
@@ -1,12 +1,15 @@
1
1
  <script lang="ts">import { click_outside, tooltip } from './attachments';
2
2
  import Icon from './Icon.svelte';
3
- let { routes = [], children, item, link, menu_props, link_props, page, labels, tooltips, tooltip_options, breakpoint = 767, onnavigate, onopen, onclose, ...rest } = $props();
3
+ import { get_uuid } from './utils';
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();
4
5
  let is_open = $state(false);
5
6
  let hovered_dropdown = $state(null);
7
+ let pinned_dropdown = $state(null);
6
8
  let focused_item_index = $state(-1);
7
9
  let is_touch_device = $state(false);
8
10
  let is_mobile = $state(false);
9
- const panel_id = `nav-menu-${crypto.randomUUID()}`;
11
+ let hide_timeout = null;
12
+ const panel_id = `nav-menu-${get_uuid()}`;
10
13
  // Track previous is_open state for callbacks
11
14
  let prev_is_open = $state(false);
12
15
  // Detect touch device and handle responsive breakpoint
@@ -32,22 +35,49 @@ $effect(() => {
32
35
  }
33
36
  prev_is_open = is_open;
34
37
  });
38
+ $effect(() => () => {
39
+ if (hide_timeout)
40
+ clearTimeout(hide_timeout);
41
+ }); // cleanup on destroy
35
42
  function close_menus() {
43
+ if (hide_timeout)
44
+ clearTimeout(hide_timeout);
36
45
  is_open = false;
37
46
  hovered_dropdown = null;
47
+ pinned_dropdown = null;
38
48
  focused_item_index = -1;
39
49
  }
40
50
  function toggle_dropdown(href, focus_first = false) {
41
- const is_opening = hovered_dropdown !== href;
42
- hovered_dropdown = hovered_dropdown === href ? null : href;
51
+ const is_opening = pinned_dropdown !== href;
52
+ pinned_dropdown = is_opening ? href : null;
53
+ hovered_dropdown = is_opening ? href : null;
43
54
  focused_item_index = is_opening && focus_first ? 0 : -1;
44
- // Focus management for keyboard users
45
55
  if (is_opening && focus_first) {
46
56
  setTimeout(() => {
47
- document.querySelector(`.dropdown[data-href="${href}"] [role="menuitem"]`)?.focus();
57
+ document
58
+ .querySelector(`.dropdown[data-href="${CSS.escape(href)}"] [role="menuitem"]`)
59
+ ?.focus();
48
60
  }, 0);
49
61
  }
50
62
  }
63
+ function open_dropdown(href, from_mouse = false) {
64
+ if (from_mouse && is_touch_device)
65
+ return;
66
+ if (hide_timeout)
67
+ clearTimeout(hide_timeout);
68
+ if (pinned_dropdown && pinned_dropdown !== href)
69
+ pinned_dropdown = null;
70
+ hovered_dropdown = href;
71
+ }
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);
80
+ }
51
81
  function onkeydown(event) {
52
82
  if (event.key === `Escape`)
53
83
  close_menus();
@@ -59,15 +89,18 @@ function handle_dropdown_keydown(event, href, sub_routes) {
59
89
  toggle_dropdown(href, true);
60
90
  return;
61
91
  }
92
+ // Check if dropdown is open (either via hover or pinned)
93
+ const is_open = hovered_dropdown === href || pinned_dropdown === href;
62
94
  // Arrow key navigation within open dropdown
63
- if (hovered_dropdown === href && (key === `ArrowDown` || key === `ArrowUp`)) {
95
+ if (is_open && (key === `ArrowDown` || key === `ArrowUp`)) {
64
96
  event.preventDefault();
65
97
  const direction = key === `ArrowDown` ? 1 : -1;
66
98
  focused_item_index = Math.max(0, Math.min(sub_routes.length - 1, focused_item_index + direction));
67
- document.querySelectorAll(`.dropdown[data-href="${href}"] [role="menuitem"]`)?.[focused_item_index]?.focus();
99
+ document
100
+ .querySelectorAll(`.dropdown[data-href="${CSS.escape(href)}"] [role="menuitem"]`)?.[focused_item_index]?.focus();
68
101
  }
69
102
  // Open dropdown with ArrowDown when closed
70
- if (hovered_dropdown !== href && key === `ArrowDown`) {
103
+ if (!is_open && key === `ArrowDown`) {
71
104
  event.preventDefault();
72
105
  toggle_dropdown(href, true);
73
106
  }
@@ -76,7 +109,9 @@ function handle_dropdown_item_keydown(event, href) {
76
109
  if (event.key === `Escape`) {
77
110
  event.preventDefault();
78
111
  close_menus();
79
- document.querySelector(`.dropdown[data-href="${href}"] [data-dropdown-toggle]`)?.focus();
112
+ document
113
+ .querySelector(`.dropdown[data-href="${href}"] [data-dropdown-toggle]`)
114
+ ?.focus();
80
115
  }
81
116
  }
82
117
  function is_current(path) {
@@ -195,7 +230,7 @@ function get_external_attrs(route) {
195
230
  <button
196
231
  class="burger"
197
232
  type="button"
198
- onclick={() => is_open = !is_open}
233
+ onclick={() => (is_open = !is_open)}
199
234
  aria-label="Toggle navigation menu"
200
235
  aria-expanded={is_open}
201
236
  aria-controls={panel_id}
@@ -222,7 +257,7 @@ function get_external_attrs(route) {
222
257
  ? route
223
258
  : Array.isArray(route)
224
259
  ? route[0]
225
- : route.href ?? `sep-${route_idx}`
260
+ : (route.href ?? `sep-${route_idx}`)
226
261
  }`)
227
262
  }
228
263
  {@const parsed_route = parse_route(route)}
@@ -240,7 +275,11 @@ function get_external_attrs(route) {
240
275
  <!-- Dropdown menu item -->
241
276
  {@const child_is_active = is_child_current(sub_routes)}
242
277
  {@const parent_page_exists = sub_routes.includes(parsed_route.href)}
243
- {@const filtered_sub_routes = sub_routes.filter((r) => r !== parsed_route.href)}
278
+ {@const filtered_sub_routes = sub_routes.filter(
279
+ (r) => r !== parsed_route.href,
280
+ )}
281
+ {@const is_pinned = pinned_dropdown === parsed_route.href}
282
+ {@const dropdown_open = hovered_dropdown === parsed_route.href || is_pinned}
244
283
  <div
245
284
  class="dropdown"
246
285
  class:active={child_is_active}
@@ -248,13 +287,13 @@ function get_external_attrs(route) {
248
287
  data-href={parsed_route.href}
249
288
  role="group"
250
289
  aria-current={child_is_active ? `true` : undefined}
251
- onmouseenter={() => !is_touch_device && (hovered_dropdown = parsed_route.href)}
252
- onmouseleave={() => !is_touch_device && (hovered_dropdown = null)}
253
- onfocusin={() => (hovered_dropdown = 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)}
254
293
  onfocusout={(event) => {
255
294
  const next = event.relatedTarget as Node | null
256
295
  if (!next || !(event.currentTarget as HTMLElement).contains(next)) {
257
- hovered_dropdown = null
296
+ if (!is_pinned) hovered_dropdown = null
258
297
  }
259
298
  }}
260
299
  >
@@ -287,9 +326,11 @@ function get_external_attrs(route) {
287
326
  {/if}
288
327
  <button
289
328
  type="button"
329
+ class="dropdown-toggle"
330
+ class:open={dropdown_open}
290
331
  data-dropdown-toggle
291
332
  aria-label="Toggle {formatted.label} submenu"
292
- aria-expanded={hovered_dropdown === parsed_route.href}
333
+ aria-expanded={dropdown_open}
293
334
  aria-haspopup="true"
294
335
  onclick={() => toggle_dropdown(parsed_route.href, false)}
295
336
  onkeydown={(event) =>
@@ -299,25 +340,15 @@ function get_external_attrs(route) {
299
340
  filtered_sub_routes,
300
341
  )}
301
342
  >
302
- <Icon
303
- icon="ChevronExpand"
304
- style="width: 0.8em; height: 0.8em"
305
- />
343
+ <Icon icon="ChevronDown" style="width: 0.7em; height: 0.7em" />
306
344
  </button>
307
345
  </div>
308
346
  <div
309
- class:visible={hovered_dropdown === parsed_route.href}
347
+ class:visible={dropdown_open}
310
348
  role="menu"
311
349
  tabindex="-1"
312
- onmouseenter={() => !is_touch_device && (hovered_dropdown = parsed_route.href)}
313
- onmouseleave={() => !is_touch_device && (hovered_dropdown = null)}
314
- onfocusin={() => (hovered_dropdown = parsed_route.href)}
315
- onfocusout={(event) => {
316
- const next = event.relatedTarget as Node | null
317
- if (!next || !(event.currentTarget as HTMLElement).contains(next)) {
318
- hovered_dropdown = null
319
- }
320
- }}
350
+ onmouseenter={() => open_dropdown(parsed_route.href, true)}
351
+ onmouseleave={() => schedule_hide(parsed_route.href, is_pinned)}
321
352
  >
322
353
  {#each filtered_sub_routes as child_href (child_href)}
323
354
  {@const child_formatted = format_label(child_href, true)}
@@ -382,9 +413,12 @@ function get_external_attrs(route) {
382
413
  nav {
383
414
  position: relative;
384
415
  margin: -0.75em auto 1.25em;
385
- --nav-border-radius: 6pt;
416
+ --nav-border-radius: 3pt;
386
417
  --nav-surface-bg: light-dark(#fafafa, #1a1a1a);
387
- --nav-surface-border: light-dark(rgba(128, 128, 128, 0.25), rgba(200, 200, 200, 0.2));
418
+ --nav-surface-border: light-dark(
419
+ rgba(128, 128, 128, 0.25),
420
+ rgba(200, 200, 200, 0.2)
421
+ );
388
422
  --nav-surface-shadow: light-dark(
389
423
  0 2px 8px rgba(0, 0, 0, 0.15),
390
424
  0 4px 12px rgba(0, 0, 0, 0.5)
@@ -398,17 +432,21 @@ function get_external_attrs(route) {
398
432
  flex-wrap: wrap;
399
433
  padding: 0.5em;
400
434
  }
401
- .menu > span,
435
+ .menu > span {
436
+ display: flex;
437
+ align-items: center;
438
+ border-radius: var(--nav-border-radius);
439
+ background-color: var(--nav-link-bg);
440
+ transition: background-color 0.2s;
441
+ }
442
+ .menu > span:hover {
443
+ background-color: var(--nav-link-bg-hover, rgba(0, 0, 0, 0.1));
444
+ }
402
445
  .menu > span > a {
403
446
  line-height: 1.3;
404
- padding: 1pt 5pt;
405
- border-radius: var(--nav-border-radius);
447
+ padding: var(--nav-item-padding, 1pt 4pt);
406
448
  text-decoration: none;
407
449
  color: inherit;
408
- transition: background-color 0.2s;
409
- }
410
- .menu > span > a:hover {
411
- background-color: var(--nav-link-bg-hover);
412
450
  }
413
451
  .menu > span > a[aria-current='page'] {
414
452
  color: var(--nav-link-active-color);
@@ -450,24 +488,23 @@ function get_external_attrs(route) {
450
488
  content: '';
451
489
  position: absolute;
452
490
  top: 100%;
453
- left: 0;
454
- right: 0;
455
- height: var(--nav-dropdown-margin, 3pt);
491
+ left: -5pt;
492
+ right: -5pt;
493
+ height: calc(var(--nav-dropdown-margin, 2pt) + 5pt);
456
494
  }
457
495
  .dropdown > div:first-child {
458
496
  display: flex;
459
497
  align-items: center;
460
- gap: 0;
461
498
  border-radius: var(--nav-border-radius);
499
+ background-color: var(--nav-link-bg);
462
500
  transition: background-color 0.2s;
463
501
  }
464
502
  .dropdown > div:first-child:hover {
465
- background-color: var(--nav-link-bg-hover);
503
+ background-color: var(--nav-link-bg-hover, rgba(0, 0, 0, 0.1));
466
504
  }
467
- .dropdown > div:first-child > a,
468
- .dropdown > div:first-child > span {
505
+ .dropdown > div:first-child > a, .dropdown > div:first-child > span {
469
506
  line-height: 1.3;
470
- padding: 1pt 5pt;
507
+ padding: var(--nav-item-padding, 1pt 4pt);
471
508
  text-decoration: none;
472
509
  color: inherit;
473
510
  border-radius: var(--nav-border-radius) 0 0 var(--nav-border-radius);
@@ -476,7 +513,7 @@ function get_external_attrs(route) {
476
513
  color: var(--nav-link-active-color);
477
514
  }
478
515
  .dropdown > div:first-child > button {
479
- padding: 1pt 3pt;
516
+ padding: 2pt 4pt;
480
517
  border: none;
481
518
  background: transparent;
482
519
  color: inherit;
@@ -486,40 +523,52 @@ function get_external_attrs(route) {
486
523
  justify-content: center;
487
524
  border-radius: 0 var(--nav-border-radius) var(--nav-border-radius) 0;
488
525
  outline-offset: -1px;
526
+ opacity: 0.6;
527
+ transition: opacity 0.15s, transform 0.2s ease;
528
+ }
529
+ .dropdown > div:first-child > button:hover {
530
+ opacity: 1;
531
+ }
532
+ .dropdown > div:first-child > button.open {
533
+ opacity: 1;
534
+ transform: rotate(180deg);
489
535
  }
490
536
  .dropdown > div:first-child > button:focus-visible {
491
537
  outline: 2px solid currentColor;
492
538
  outline-offset: -2px;
539
+ opacity: 1;
493
540
  }
494
541
  .dropdown > div:last-child {
495
542
  position: absolute;
496
543
  top: 100%;
497
- left: 0;
498
- margin: var(--nav-dropdown-margin, 3pt 0 0 0);
499
- min-width: max-content;
544
+ left: var(--nav-dropdown-left, 0);
545
+ right: var(--nav-dropdown-right, auto);
546
+ margin: var(--nav-dropdown-margin, 2pt) 0 0 0;
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 */
500
550
  background-color: var(--nav-dropdown-bg, var(--nav-surface-bg));
501
551
  border: 1px solid var(--nav-dropdown-border-color, var(--nav-surface-border));
502
552
  border-radius: var(--nav-border-radius, 6pt);
503
553
  box-shadow: var(--nav-dropdown-shadow, var(--nav-surface-shadow));
504
- padding: var(--nav-dropdown-padding, 2pt 3pt);
554
+ padding: var(--nav-dropdown-padding, 3pt 0);
505
555
  display: none;
506
556
  flex-direction: column;
507
- gap: var(--nav-dropdown-gap, 5pt);
508
557
  z-index: var(--nav-dropdown-z-index, 100);
509
558
  }
510
559
  .dropdown > div:last-child.visible {
511
560
  display: flex;
512
561
  }
513
562
  .dropdown > div:last-child a {
514
- padding: var(--nav-dropdown-link-padding, 1pt 4pt);
515
- border-radius: var(--nav-border-radius);
563
+ padding: var(--nav-dropdown-link-padding, 2pt 6pt);
516
564
  text-decoration: none;
517
565
  color: inherit;
518
566
  white-space: nowrap;
519
- transition: background-color 0.2s;
567
+ font-size: 0.92em;
568
+ transition: background-color 0.15s;
520
569
  }
521
570
  .dropdown > div:last-child a:hover {
522
- background-color: var(--nav-link-bg-hover);
571
+ background-color: var(--nav-link-bg-hover, rgba(0, 0, 0, 0.1));
523
572
  }
524
573
  .dropdown > div:last-child a[aria-current='page'] {
525
574
  color: var(--nav-link-active-color);
@@ -610,15 +659,29 @@ function get_external_attrs(route) {
610
659
  nav.mobile .dropdown > div:first-child > button {
611
660
  padding: 4pt 8pt;
612
661
  border-radius: var(--nav-border-radius);
662
+ opacity: 0.6;
663
+ }
664
+ nav.mobile .dropdown > div:first-child > button.open {
665
+ opacity: 1;
613
666
  }
614
667
  nav.mobile .dropdown > div:last-child {
615
668
  position: static;
616
669
  border: none;
617
670
  box-shadow: none;
618
- margin-top: 0.25em;
619
- padding: 0 0 0 1em;
671
+ margin-top: 2pt;
672
+ padding: 0;
620
673
  background-color: transparent;
621
674
  }
675
+ nav.mobile .dropdown > div:last-child a {
676
+ padding: 4pt 8pt 4pt 6pt;
677
+ margin-left: 8pt;
678
+ border-left: 2px solid transparent;
679
+ font-size: 0.9em;
680
+ }
681
+ nav.mobile .dropdown > div:last-child a:hover,
682
+ nav.mobile .dropdown > div:last-child a[aria-current='page'] {
683
+ border-left-color: var(--nav-link-active-color, currentColor);
684
+ }
622
685
  /* Mobile right-aligned items stack normally */
623
686
  nav.mobile .menu > .align-right,
624
687
  nav.mobile .menu > .dropdown.align-right {
@@ -1,7 +1,7 @@
1
1
  import type { Page } from '@sveltejs/kit';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import { type TooltipOptions } from './attachments';
4
+ import type { TooltipOptions } from './attachments';
5
5
  import type { NavRoute, NavRouteObject } from './types';
6
6
  interface ItemSnippetParams {
7
7
  route: NavRouteObject;
@@ -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;
@@ -1,4 +1,6 @@
1
1
  import { type Attachment } from 'svelte/attachments';
2
+ import { get_uuid } from './utils';
3
+ export { get_uuid };
2
4
  declare global {
3
5
  interface CSS {
4
6
  highlights: HighlightRegistry;
@@ -62,6 +64,7 @@ export interface TooltipOptions {
62
64
  show_arrow?: boolean;
63
65
  offset?: number;
64
66
  allow_html?: boolean;
67
+ sanitize_html?: (html: string) => string;
65
68
  }
66
69
  export declare const tooltip: (options?: TooltipOptions) => Attachment;
67
70
  export type ClickOutsideConfig<T extends HTMLElement> = {
@@ -1,4 +1,7 @@
1
1
  import {} from 'svelte/attachments';
2
+ import { get_uuid } from './utils';
3
+ // Re-export get_uuid for backwards compatibility
4
+ export { get_uuid };
2
5
  // Svelte 5 attachment factory to make an element draggable
3
6
  // @param options - Configuration options for dragging behavior
4
7
  // @returns Attachment function that sets up dragging on an element
@@ -419,16 +422,141 @@ export const tooltip = (options = {}) => (node) => {
419
422
  content = new_content;
420
423
  // Only update tooltip if this element owns it
421
424
  if (current_tooltip?._owner === element) {
422
- if (options.allow_html !== false) {
423
- current_tooltip.innerHTML = content.replace(/\r/g, `<br/>`);
424
- }
425
- else {
426
- 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);
427
438
  }
428
439
  }
429
440
  }
430
441
  });
431
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
+ }
432
560
  function show_tooltip() {
433
561
  // Skip tooltip on touch input when 'touch-devices' option is set
434
562
  if (options.disabled === `touch-devices` && last_pointer_type === `touch`)
@@ -440,13 +568,13 @@ export const tooltip = (options = {}) => (node) => {
440
568
  const placement = options.placement || `bottom`;
441
569
  tooltip_el.setAttribute(`data-placement`, placement);
442
570
  // Accessibility: link tooltip to trigger element
443
- const tooltip_id = `tooltip-${crypto.randomUUID()}`;
571
+ const tooltip_id = `tooltip-${get_uuid()}`;
444
572
  tooltip_el.id = tooltip_id;
445
573
  tooltip_el.setAttribute(`role`, `tooltip`);
446
574
  element.setAttribute(`aria-describedby`, tooltip_id);
447
575
  // Apply base styles
448
576
  tooltip_el.style.cssText = `
449
- position: absolute; z-index: 9999; opacity: 0;
577
+ position: absolute; z-index: 9999; opacity: 0; display: inline-block;
450
578
  background: var(--tooltip-bg, #333); color: var(--text-color, white); border: var(--tooltip-border, none);
451
579
  padding: var(--tooltip-padding, 6px 10px); border-radius: var(--tooltip-radius, 6px); font-size: var(--tooltip-font-size, 13px); line-height: 1.4;
452
580
  max-width: var(--tooltip-max-width, 280px); word-wrap: break-word; text-wrap: balance; pointer-events: none;
@@ -462,13 +590,20 @@ export const tooltip = (options = {}) => (node) => {
462
590
  tooltip_el.style.setProperty(property, value);
463
591
  });
464
592
  }
593
+ // Wrap content in a span for reactive content updates
594
+ const content_span = document.createElement(`span`);
595
+ content_span.className = `tooltip-content`;
465
596
  // Security: allow_html defaults to true; set to false for plain text rendering
466
597
  if (options.allow_html !== false) {
467
- 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;
468
602
  }
469
603
  else {
470
- tooltip_el.textContent = content ?? ``;
604
+ content_span.textContent = content ?? ``;
471
605
  }
606
+ tooltip_el.appendChild(content_span);
472
607
  // Mirror CSS custom properties from the trigger node onto the tooltip element
473
608
  const trigger_styles = getComputedStyle(element);
474
609
  [
@@ -575,34 +710,7 @@ export const tooltip = (options = {}) => (node) => {
575
710
  append_border_arrow();
576
711
  tooltip_el.appendChild(arrow);
577
712
  }
578
- // Position tooltip
579
- const rect = element.getBoundingClientRect();
580
- const tooltip_rect = tooltip_el.getBoundingClientRect();
581
- const margin = options.offset ?? 12;
582
- let top = 0, left = 0;
583
- if (placement === `top`) {
584
- top = rect.top - tooltip_rect.height - margin;
585
- left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
586
- }
587
- else if (placement === `left`) {
588
- top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
589
- left = rect.left - tooltip_rect.width - margin;
590
- }
591
- else if (placement === `right`) {
592
- top = rect.top + rect.height / 2 - tooltip_rect.height / 2;
593
- left = rect.right + margin;
594
- }
595
- else { // bottom
596
- top = rect.bottom + margin;
597
- left = rect.left + rect.width / 2 - tooltip_rect.width / 2;
598
- }
599
- // Keep in viewport
600
- left = Math.max(8, Math.min(left, globalThis.innerWidth - tooltip_rect.width - 8));
601
- top = Math.max(8, Math.min(top, globalThis.innerHeight - tooltip_rect.height - 8));
602
- tooltip_el.style.left = `${left + globalThis.scrollX}px`;
603
- tooltip_el.style.top = `${top + globalThis.scrollY}px`;
604
- const custom_opacity = trigger_styles.getPropertyValue(`--tooltip-opacity`).trim();
605
- tooltip_el.style.opacity = custom_opacity || `1`;
713
+ resize_and_position_tooltip(tooltip_el, element);
606
714
  current_tooltip = Object.assign(tooltip_el, { _owner: element });
607
715
  }, options.delay || 100);
608
716
  }