@sveltia/ui 0.78.4 → 0.79.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.
Files changed (183) hide show
  1. package/dist/components/alert/alert-content.svelte +51 -0
  2. package/dist/components/alert/alert-content.svelte.d.ts +39 -0
  3. package/dist/components/alert/alert.d.ts +3 -0
  4. package/dist/components/alert/alert.js +21 -0
  5. package/dist/components/alert/alert.svelte +6 -30
  6. package/dist/components/alert/infobar.svelte +7 -21
  7. package/dist/components/alert/infobar.svelte.d.ts +2 -2
  8. package/dist/components/button/button.svelte +11 -11
  9. package/dist/components/button/button.svelte.d.ts +2 -2
  10. package/dist/components/button/select-button.svelte +1 -1
  11. package/dist/components/button/select-button.svelte.d.ts +2 -2
  12. package/dist/components/button/split-button.svelte +1 -1
  13. package/dist/components/button/split-button.svelte.d.ts +3 -3
  14. package/dist/components/calendar/calendar.d.ts +2 -0
  15. package/dist/components/calendar/calendar.js +34 -0
  16. package/dist/components/calendar/calendar.svelte +49 -5
  17. package/dist/components/checkbox/checkbox.svelte +11 -4
  18. package/dist/components/checkbox/checkbox.svelte.d.ts +2 -2
  19. package/dist/components/dialog/alert-dialog.svelte +1 -1
  20. package/dist/components/dialog/alert-dialog.svelte.d.ts +2 -2
  21. package/dist/components/dialog/confirmation-dialog.svelte +1 -1
  22. package/dist/components/dialog/confirmation-dialog.svelte.d.ts +2 -2
  23. package/dist/components/dialog/dialog.svelte +8 -18
  24. package/dist/components/dialog/dialog.svelte.d.ts +2 -2
  25. package/dist/components/dialog/modal-close-button.svelte +50 -0
  26. package/dist/components/dialog/modal-close-button.svelte.d.ts +43 -0
  27. package/dist/components/dialog/prompt-dialog.svelte +1 -1
  28. package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -4
  29. package/dist/components/disclosure/disclosure.svelte +1 -1
  30. package/dist/components/drawer/drawer.svelte +12 -34
  31. package/dist/components/drawer/drawer.svelte.d.ts +1 -1
  32. package/dist/components/emoji/caret.d.ts +1 -1
  33. package/dist/components/emoji/caret.js +1 -1
  34. package/dist/components/emoji/emoji-suggestions.svelte +1 -1
  35. package/dist/components/emoji/emoji-suggestions.svelte.d.ts +3 -3
  36. package/dist/components/emoji/emoji.d.ts +2 -2
  37. package/dist/components/emoji/emoji.js +1 -1
  38. package/dist/components/emoji/position.d.ts +2 -2
  39. package/dist/components/emoji/position.js +1 -1
  40. package/dist/components/grid/grid-body.svelte +6 -2
  41. package/dist/components/grid/grid-cell.svelte +3 -9
  42. package/dist/components/grid/grid-col-header.svelte +3 -9
  43. package/dist/components/grid/grid-foot.svelte +4 -11
  44. package/dist/components/grid/grid-head.svelte +4 -11
  45. package/dist/components/grid/grid-row-header.svelte +3 -10
  46. package/dist/components/grid/grid-row.svelte +3 -16
  47. package/dist/components/listbox/listbox.svelte +1 -1
  48. package/dist/components/listbox/listbox.svelte.d.ts +1 -1
  49. package/dist/components/listbox/option.svelte +1 -1
  50. package/dist/components/listbox/option.svelte.d.ts +2 -2
  51. package/dist/components/menu/menu-button.svelte +2 -2
  52. package/dist/components/menu/menu-button.svelte.d.ts +4 -4
  53. package/dist/components/menu/menu-item-checkable.svelte +71 -0
  54. package/dist/components/menu/menu-item-checkable.svelte.d.ts +25 -0
  55. package/dist/components/menu/menu-item-checkbox.svelte +3 -36
  56. package/dist/components/menu/menu-item-checkbox.svelte.d.ts +3 -3
  57. package/dist/components/menu/menu-item-group.svelte +8 -5
  58. package/dist/components/menu/menu-item-group.svelte.d.ts +9 -1
  59. package/dist/components/menu/menu-item-radio.svelte +3 -36
  60. package/dist/components/menu/menu-item-radio.svelte.d.ts +3 -3
  61. package/dist/components/menu/menu-item.svelte +22 -6
  62. package/dist/components/menu/menu-item.svelte.d.ts +4 -4
  63. package/dist/components/radio/radio.svelte +12 -1
  64. package/dist/components/resizable-pane/resizable-handle.svelte +14 -18
  65. package/dist/components/resizable-pane/resizable-pane-group.svelte +9 -6
  66. package/dist/components/resizable-pane/resizable-pane.svelte +4 -4
  67. package/dist/components/resizable-pane/resizable-pane.svelte.d.ts +7 -6
  68. package/dist/components/resizable-pane/sizing.d.ts +5 -4
  69. package/dist/components/resizable-pane/sizing.js +84 -25
  70. package/dist/components/scroll/infinite-scroll.svelte +16 -21
  71. package/dist/components/select/combobox.svelte +43 -52
  72. package/dist/components/select/combobox.svelte.d.ts +2 -2
  73. package/dist/components/select/select-tags.svelte +7 -1
  74. package/dist/components/select/select.svelte +2 -22
  75. package/dist/components/select/select.svelte.d.ts +1 -1
  76. package/dist/components/slider/slider.d.ts +6 -0
  77. package/dist/components/slider/slider.js +55 -2
  78. package/dist/components/slider/slider.svelte +63 -32
  79. package/dist/components/table/table-cell.svelte +3 -9
  80. package/dist/components/table/table-col-header.svelte +3 -9
  81. package/dist/components/table/table-foot.svelte +4 -11
  82. package/dist/components/table/table-head.svelte +4 -11
  83. package/dist/components/table/table-part.svelte +69 -0
  84. package/dist/components/table/table-part.svelte.d.ts +48 -0
  85. package/dist/components/table/table-row-header.svelte +3 -10
  86. package/dist/components/table/table-row.svelte +3 -10
  87. package/dist/components/tabs/tab-list.svelte +12 -0
  88. package/dist/components/tabs/tab.svelte +1 -1
  89. package/dist/components/tabs/tab.svelte.d.ts +2 -2
  90. package/dist/components/text-editor/constants.d.ts +5 -5
  91. package/dist/components/text-editor/constants.js +1 -1
  92. package/dist/components/text-editor/core.d.ts +4 -5
  93. package/dist/components/text-editor/core.js +372 -285
  94. package/dist/components/text-editor/emoji-autocomplete.svelte +1 -1
  95. package/dist/components/text-editor/lexical-root.svelte +4 -2
  96. package/dist/components/text-editor/raw-markdown.d.ts +6 -33
  97. package/dist/components/text-editor/raw-markdown.js +63 -8
  98. package/dist/components/text-editor/shiki/facade.d.ts +1 -1
  99. package/dist/components/text-editor/shiki/facade.js +63 -48
  100. package/dist/components/text-editor/shiki/generated.d.ts +1 -1
  101. package/dist/components/text-editor/shiki/generated.js +1 -1
  102. package/dist/components/text-editor/shiki/highlighter.d.ts +6 -1
  103. package/dist/components/text-editor/shiki/highlighter.js +3 -4
  104. package/dist/components/text-editor/shiki/loader.d.ts +1 -1
  105. package/dist/components/text-editor/shiki/loader.js +1 -1
  106. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  107. package/dist/components/text-editor/shiki/version.js +1 -1
  108. package/dist/components/text-editor/store.svelte.d.ts +1 -1
  109. package/dist/components/text-editor/store.svelte.js +29 -2
  110. package/dist/components/text-editor/text-editor.svelte +2 -2
  111. package/dist/components/text-editor/text-editor.svelte.d.ts +3 -3
  112. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -16
  113. package/dist/components/text-editor/toolbar/format-text-button.svelte +7 -31
  114. package/dist/components/text-editor/toolbar/format-text-button.svelte.d.ts +1 -1
  115. package/dist/components/text-editor/toolbar/insert-component.d.ts +3 -0
  116. package/dist/components/text-editor/toolbar/insert-component.js +34 -0
  117. package/dist/components/text-editor/toolbar/insert-item-button.svelte +4 -24
  118. package/dist/components/text-editor/toolbar/insert-item-button.svelte.d.ts +1 -1
  119. package/dist/components/text-editor/toolbar/insert-link-button.svelte +9 -25
  120. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +4 -24
  121. package/dist/components/text-editor/toolbar/insert-menu-button.svelte.d.ts +1 -1
  122. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +1 -1
  123. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +31 -35
  124. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte.d.ts +1 -1
  125. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte +0 -10
  126. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte.d.ts +1 -11
  127. package/dist/components/text-editor/transformers/table.d.ts +0 -1
  128. package/dist/components/text-editor/transformers/table.js +1 -2
  129. package/dist/components/text-field/emoji-autocomplete.svelte +1 -1
  130. package/dist/components/text-field/masked-input.svelte +179 -0
  131. package/dist/components/text-field/masked-input.svelte.d.ts +42 -0
  132. package/dist/components/text-field/number-input.js +7 -1
  133. package/dist/components/text-field/number-input.svelte +13 -5
  134. package/dist/components/text-field/number-input.svelte.d.ts +3 -3
  135. package/dist/components/text-field/password-input.svelte +3 -116
  136. package/dist/components/text-field/password-input.svelte.d.ts +3 -3
  137. package/dist/components/text-field/search-bar.svelte +1 -1
  138. package/dist/components/text-field/search-bar.svelte.d.ts +3 -3
  139. package/dist/components/text-field/secret-input.svelte +3 -119
  140. package/dist/components/text-field/secret-input.svelte.d.ts +3 -3
  141. package/dist/components/text-field/text-area.svelte +1 -1
  142. package/dist/components/text-field/text-area.svelte.d.ts +3 -3
  143. package/dist/components/text-field/text-input.svelte +1 -1
  144. package/dist/components/text-field/text-input.svelte.d.ts +3 -3
  145. package/dist/components/toast/toast.svelte +11 -20
  146. package/dist/components/toast/toast.svelte.d.ts +1 -1
  147. package/dist/components/tree/tree-item.svelte +1 -1
  148. package/dist/components/tree/tree-item.svelte.d.ts +1 -1
  149. package/dist/components/tree/tree.svelte +1 -1
  150. package/dist/components/tree/tree.svelte.d.ts +1 -1
  151. package/dist/components/util/font-links.svelte +1 -0
  152. package/dist/components/util/modal.svelte +37 -1
  153. package/dist/components/util/modal.svelte.d.ts +1 -1
  154. package/dist/components/util/popup.svelte +2 -5
  155. package/dist/components/util/popup.svelte.d.ts +2 -2
  156. package/dist/components/util/visibility-observer.svelte +1 -0
  157. package/dist/locales/ar.yaml +1 -1
  158. package/dist/locales/sk.yaml +136 -0
  159. package/dist/services/group.svelte.d.ts +10 -1
  160. package/dist/services/group.svelte.js +361 -251
  161. package/dist/services/navigation.d.ts +19 -0
  162. package/dist/services/navigation.js +84 -0
  163. package/dist/services/popup-position.d.ts +90 -0
  164. package/dist/services/popup-position.js +275 -0
  165. package/dist/services/popup.svelte.d.ts +16 -17
  166. package/dist/services/popup.svelte.js +25 -169
  167. package/dist/services/scroll.d.ts +1 -0
  168. package/dist/services/scroll.js +17 -0
  169. package/dist/services/select.svelte.d.ts +2 -2
  170. package/dist/services/select.svelte.js +1 -1
  171. package/dist/services/text.d.ts +2 -0
  172. package/dist/services/text.js +28 -0
  173. package/dist/services/tree.svelte.d.ts +3 -11
  174. package/dist/services/tree.svelte.js +35 -77
  175. package/dist/services/type-ahead.d.ts +0 -4
  176. package/dist/services/type-ahead.js +3 -1
  177. package/dist/services/widget.d.ts +8 -0
  178. package/dist/services/widget.js +39 -0
  179. package/dist/shiki-engine.js +15 -15
  180. package/dist/styles/mixins.scss +126 -0
  181. package/dist/typedefs.d.ts +5 -0
  182. package/dist/typedefs.js +2 -0
  183. package/package.json +24 -20
@@ -2,8 +2,11 @@ import { isRTL } from '@sveltia/i18n';
2
2
  import { generateElementId } from '@sveltia/utils/element';
3
3
  import { sleep } from '@sveltia/utils/misc';
4
4
  import { on } from 'svelte/events';
5
+ import { calculatePopupStyle, isShallowEqual, mirrorPosition } from './popup-position.js';
6
+
5
7
  /**
6
- * @import { PopupPosition } from '../typedefs';
8
+ * @import { PopupPosition } from '../typedefs.js';
9
+ * @import { PopupStyle } from './popup-position.js';
7
10
  */
8
11
 
9
12
  /**
@@ -51,10 +54,7 @@ class Popup {
51
54
  }
52
55
 
53
56
  style = $state(
54
- /**
55
- * @type {{ inset: string | undefined, zIndex: number | undefined, minWidth: string | undefined,
56
- * maxWidth: string | undefined, height: string | undefined }}
57
- */
57
+ /** @type {PopupStyle} */
58
58
  ({
59
59
  inset: undefined,
60
60
  zIndex: undefined,
@@ -93,11 +93,17 @@ class Popup {
93
93
  */
94
94
  #removeAnchorListeners = [];
95
95
 
96
+ /**
97
+ * ID of the animation frame requested to recalculate the position after a resize.
98
+ * @type {number}
99
+ */
100
+ _rafId = 0;
101
+
96
102
  /**
97
103
  * Initialize a new `Popup` instance. Note that the `popupElement` is optional, because the
98
104
  * element is typically mounted only while the popup is open. Use {@link attachPopupElement} to
99
105
  * provide it later.
100
- * @param {HTMLButtonElement} anchorElement `<button>` element that triggers the popup.
106
+ * @param {HTMLElement} anchorElement Element that triggers the popup, typically a `<button>`.
101
107
  * @param {HTMLDialogElement | undefined} popupElement `<dialog>` element to be used for the
102
108
  * popup, if it’s already in the DOM tree.
103
109
  * @param {PopupPosition} position Where to show the popup content.
@@ -260,9 +266,8 @@ class Popup {
260
266
  }
261
267
 
262
268
  /**
263
- * Detach the `<dialog>` element, typically because it’s being unmounted. The `aria-controls`
264
- * attribute on the anchor is left to the {@link open} setter, which removes it once the closing
265
- * animation is complete.
269
+ * Detach the `<dialog>` element, typically because it’s being unmounted. The anchor’s
270
+ * `aria-controls` attribute is removed as well, since the content is leaving the DOM tree.
266
271
  */
267
272
  detachPopupElement() {
268
273
  this.#removeEventListeners?.();
@@ -325,23 +330,6 @@ class Popup {
325
330
  return;
326
331
  }
327
332
 
328
- const anchorRect = this.positionBaseElement.getBoundingClientRect();
329
- const rootBounds = { width: window.innerWidth, height: window.innerHeight };
330
-
331
- // Clip the anchor’s rect to the viewport, mirroring what `IntersectionObserver` used to report
332
- // as `intersectionRect`
333
- const intersectionRect = {
334
- top: Math.max(anchorRect.top, 0),
335
- left: Math.max(anchorRect.left, 0),
336
- right: Math.min(anchorRect.right, rootBounds.width),
337
- bottom: Math.min(anchorRect.bottom, rootBounds.height),
338
- width: 0,
339
- height: 0,
340
- };
341
-
342
- intersectionRect.width = Math.max(0, intersectionRect.right - intersectionRect.left);
343
- intersectionRect.height = Math.max(0, intersectionRect.bottom - intersectionRect.top);
344
-
345
333
  // Measure the content at its natural size, with any limit previously applied here taken off
346
334
  // first. A popup that delegates its scrolling to a child — the combobox hands it to the option
347
335
  // list, so the filter stays put — reports a `scrollHeight` no larger than the `max-height` it’s
@@ -349,6 +337,7 @@ class Popup {
349
337
  // clipped values would make the cap stick: once the popup had shrunk to fit a small viewport,
350
338
  // it could never grow back when the viewport did. The properties are put back synchronously,
351
339
  // before the browser can paint, so nothing flickers.
340
+ const anchorRect = this.positionBaseElement.getBoundingClientRect();
352
341
  const { maxHeight: appliedMaxHeight, maxWidth: appliedMaxWidth } = content.style;
353
342
 
354
343
  content.style.maxHeight = '';
@@ -359,148 +348,16 @@ class Popup {
359
348
  content.style.maxHeight = appliedMaxHeight;
360
349
  content.style.maxWidth = appliedMaxWidth;
361
350
 
362
- const topMargin = intersectionRect.top - 8;
363
- const bottomMargin = rootBounds.height - intersectionRect.bottom - 8;
364
- // A popup that opens beside its anchor is aligned with one of the anchor’s edges and extends
365
- // across it, so its room is measured from that edge, not from the opposite one a dropdown
366
- // hangs off
367
- const leftMargin = intersectionRect.left - 8;
368
- const rightMargin = rootBounds.width - intersectionRect.right - 8;
369
- const downwardMargin = rootBounds.height - intersectionRect.top - 8;
370
- const upwardMargin = intersectionRect.bottom - 8;
371
- let { position } = this;
372
- let height;
373
-
374
- // Normalize RTL-friendly positions to LTR for LTR documents
375
- // @todo Rename `PopupPosition` enums to be direction-agnostic
376
- if (isRTL()) {
377
- if (position.endsWith('-left')) {
378
- position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
379
- } else if (position.endsWith('-right')) {
380
- position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
381
- }
382
-
383
- if (position.startsWith('left-')) {
384
- position = /** @type {PopupPosition} */ (position.replace('left-', 'right-'));
385
- } else if (position.startsWith('right-')) {
386
- position = /** @type {PopupPosition} */ (position.replace('right-', 'left-'));
387
- }
388
- }
389
-
390
- // Alter the position if the space is limited
391
- if (position.startsWith('bottom-')) {
392
- if (contentHeight > bottomMargin) {
393
- if (topMargin > bottomMargin) {
394
- position = /** @type {PopupPosition} */ (position.replace('bottom-', 'top-'));
395
- height = topMargin;
396
- } else {
397
- height = bottomMargin;
398
- }
399
- }
400
- }
401
-
402
- // Only a popup that opens beside its anchor carries a `-top`/`-bottom` suffix, and it grows
403
- // away from the edge it’s aligned with: down from the anchor’s top, or up from its bottom. It
404
- // gets the same treatment as the dropdown above — align with the other edge when the content
405
- // doesn’t fit and that edge has more room, and cap it either way, so a long submenu scrolls
406
- // instead of running past the viewport
407
- if (position.endsWith('-top')) {
408
- if (contentHeight > downwardMargin) {
409
- if (upwardMargin > downwardMargin) {
410
- position = /** @type {PopupPosition} */ (position.replace('-top', '-bottom'));
411
- height = upwardMargin;
412
- } else {
413
- height = downwardMargin;
414
- }
415
- }
416
- } else if (position.endsWith('-bottom')) {
417
- if (contentHeight > upwardMargin) {
418
- if (downwardMargin > upwardMargin) {
419
- position = /** @type {PopupPosition} */ (position.replace('-bottom', '-top'));
420
- height = downwardMargin;
421
- } else {
422
- height = upwardMargin;
423
- }
424
- }
425
- }
426
-
427
- // If the popup overflows the viewport, change the position
428
- if (position.endsWith('-left')) {
429
- if (intersectionRect.left + contentWidth > rootBounds.width - 8) {
430
- position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
431
- }
432
- }
433
-
434
- if (position.endsWith('-right')) {
435
- if (intersectionRect.right - contentWidth < 8) {
436
- position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
437
- }
438
- }
439
-
440
- // The two checks above align a dropdown that opens below or above its anchor, so neither
441
- // covers a popup that opens beside one — a submenu — running off the edge it opens towards.
442
- // That gets the same treatment as the vertical flip above: switch to the other side, but only
443
- // when it has more room, so a submenu with nowhere to go stays where the caller put it
444
- if (position.startsWith('right-')) {
445
- if (contentWidth > rightMargin && leftMargin > rightMargin) {
446
- position = /** @type {PopupPosition} */ (position.replace('right-', 'left-'));
447
- }
448
- } else if (position.startsWith('left-')) {
449
- if (contentWidth > leftMargin && rightMargin > leftMargin) {
450
- position = /** @type {PopupPosition} */ (position.replace('left-', 'right-'));
451
- }
452
- }
453
-
454
- const top = position.startsWith('bottom-')
455
- ? `${Math.round(intersectionRect.bottom)}px`
456
- : position.endsWith('-top')
457
- ? `${Math.round(intersectionRect.top)}px`
458
- : 'auto';
459
-
460
- const right = position.startsWith('left-')
461
- ? `${Math.round(rootBounds.width - intersectionRect.left)}px`
462
- : position.endsWith('-right')
463
- ? `${Math.round(rootBounds.width - intersectionRect.right)}px`
464
- : 'auto';
465
-
466
- const bottom = position.startsWith('top-')
467
- ? `${Math.round(rootBounds.height - intersectionRect.top)}px`
468
- : position.endsWith('-bottom')
469
- ? `${Math.round(rootBounds.height - intersectionRect.bottom)}px`
470
- : 'auto';
471
-
472
- const left = position.startsWith('right-')
473
- ? `${Math.round(intersectionRect.right)}px`
474
- : position.endsWith('-left')
475
- ? `${Math.round(intersectionRect.left)}px`
476
- : 'auto';
477
-
478
- const style = {
479
- inset: [top, right, bottom, left].join(' '),
480
- zIndex: 1000,
481
- minWidth: `${Math.round(intersectionRect.width)}px`,
482
- // A popup opening beside the anchor is capped by the room on that side, not by the width the
483
- // anchor’s own edges leave, which is what the two dropdown cases below measure
484
- maxWidth: position.startsWith('right-')
485
- ? `${Math.round(rightMargin)}px`
486
- : position.startsWith('left-')
487
- ? `${Math.round(leftMargin)}px`
488
- : position.endsWith('-left')
489
- ? `${Math.round(rootBounds.width - intersectionRect.left - 8)}px`
490
- : `${Math.round(intersectionRect.right - 8)}px`,
491
- // `undefined` removes the `max-height` the popup is given, letting it take its natural size
492
- // again. `auto` would look equivalent but isn’t a valid `max-height`, so the browser would
493
- // drop it and leave whatever limit was applied last in place.
494
- height: height ? `${Math.round(height)}px` : undefined,
495
- };
351
+ const style = calculatePopupStyle({
352
+ // Normalize RTL-friendly positions to LTR for LTR documents
353
+ position: isRTL() ? mirrorPosition(this.position) : this.position,
354
+ anchorRect,
355
+ viewport: { width: window.innerWidth, height: window.innerHeight },
356
+ contentWidth,
357
+ contentHeight,
358
+ });
496
359
 
497
- if (
498
- style.inset !== this.style.inset ||
499
- style.zIndex !== this.style.zIndex ||
500
- style.minWidth !== this.style.minWidth ||
501
- style.maxWidth !== this.style.maxWidth ||
502
- style.height !== this.style.height
503
- ) {
360
+ if (!isShallowEqual(style, this.style)) {
504
361
  this.style = style;
505
362
  }
506
363
  }
@@ -541,8 +398,7 @@ class Popup {
541
398
 
542
399
  /**
543
400
  * Activate a new popup.
544
- * @param {...any} args Arguments.
401
+ * @param {ConstructorParameters<typeof Popup>} args Arguments passed to the `Popup` constructor.
545
402
  * @returns {Popup} New popup.
546
403
  */
547
- // @ts-ignore
548
404
  export const activatePopup = (...args) => new Popup(...args);
@@ -0,0 +1 @@
1
+ export function scrollIntoViewIfNeeded(element: HTMLElement): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Scrolling helpers shared by the composite widgets.
3
+ */
4
+
5
+ /**
6
+ * Scroll the given element into view if needed, by as little as possible. Falls back to the legacy
7
+ * boolean argument on a browser that rejects the options object.
8
+ * @internal
9
+ * @param {HTMLElement} element Element to be scrolled into view.
10
+ */
11
+ export const scrollIntoViewIfNeeded = (element) => {
12
+ try {
13
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
14
+ } catch {
15
+ element.scrollIntoView(true);
16
+ }
17
+ };
@@ -52,5 +52,5 @@ export class OptionRegistry {
52
52
  export function createOptionRegistry(): OptionRegistry;
53
53
  export function getOptionRegistry(): OptionRegistry | undefined;
54
54
  export function getSelectedItemDetail(target: HTMLElement): SelectedItemDetail;
55
- import type { OptionEntry } from '../typedefs';
56
- import type { SelectedItemDetail } from '../typedefs';
55
+ import type { OptionEntry } from '../typedefs.js';
56
+ import type { SelectedItemDetail } from '../typedefs.js';
@@ -2,7 +2,7 @@ import { getContext, setContext } from 'svelte';
2
2
  import { castValue } from './value.js';
3
3
 
4
4
  /**
5
- * @import { OptionEntry, SelectedItemDetail } from '../typedefs';
5
+ * @import { OptionEntry, SelectedItemDetail } from '../typedefs.js';
6
6
  */
7
7
 
8
8
  /**
@@ -0,0 +1,2 @@
1
+ export function fold(value: string): string;
2
+ export function normalize(value: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Text normalization shared by search filtering and type-ahead.
3
+ */
4
+
5
+ /**
6
+ * Diacritic characters regex for normalization. We use a regex instead of `Intl` APIs for better
7
+ * performance, since `transliterate` is slow and we only need basic normalization.
8
+ */
9
+ const DIACRITIC_RE = /\p{Diacritic}/gu;
10
+
11
+ /**
12
+ * Fold the given string for comparison: strip diacritics and convert to lower case, keeping
13
+ * whitespace as is.
14
+ * @internal
15
+ * @param {string} value Original value.
16
+ * @returns {string} Folded value.
17
+ */
18
+ export const fold = (value) => value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
19
+
20
+ /**
21
+ * Normalize the given string for search value comparison. Since `transliterate` is slow, we only
22
+ * apply basic normalization.
23
+ * @internal
24
+ * @param {string} value Original value.
25
+ * @returns {string} Normalized value.
26
+ * @todo Move this to `@sveltia/utils`.
27
+ */
28
+ export const normalize = (value) => fold(value.trim());
@@ -35,16 +35,6 @@ export class Tree {
35
35
  * @type {HTMLElement | undefined}
36
36
  */
37
37
  anchor: HTMLElement | undefined;
38
- /**
39
- * Currently accumulated type-ahead search terms.
40
- * @type {string}
41
- */
42
- typeAheadTerms: string;
43
- /**
44
- * Timer used to reset the type-ahead search terms.
45
- * @type {ReturnType<typeof globalThis.setTimeout> | undefined}
46
- */
47
- typeAheadTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
48
38
  /**
49
39
  * Whether {@link activate} has run.
50
40
  * @type {boolean}
@@ -217,7 +207,8 @@ export class Tree {
217
207
  */
218
208
  expandSiblings(item: HTMLElement): void;
219
209
  /**
220
- * Move focus to the next item that matches the accumulated type-ahead search terms.
210
+ * Move focus to the next item that matches the accumulated type-ahead search terms. Typing the
211
+ * same character repeatedly cycles through the items starting with it.
221
212
  * @param {string} char Typed character.
222
213
  * @param {HTMLElement} currentItem Currently focused item.
223
214
  */
@@ -242,6 +233,7 @@ export class Tree {
242
233
  * Clean up event listeners.
243
234
  */
244
235
  destroy(): void;
236
+ #private;
245
237
  }
246
238
  export function activateTree(params?: object): Attachment;
247
239
  import type { Attachment } from 'svelte/attachments';
@@ -1,17 +1,16 @@
1
1
  import { isRTL } from '@sveltia/i18n';
2
2
  import { generateElementId } from '@sveltia/utils/element';
3
- import { sleep } from '@sveltia/utils/misc';
4
- import { normalize } from './group.svelte.js';
3
+ import { getArrowKeys } from './navigation.js';
4
+ import { scrollIntoViewIfNeeded } from './scroll.js';
5
+ import { normalize } from './text.js';
5
6
  import { getSelectedItemDetail } from './select.svelte.js';
7
+ import { findTypeAheadMatch, TypeAhead } from './type-ahead.js';
8
+ import { activateBefore, activateLater } from './widget.js';
6
9
 
7
10
  /**
8
11
  * @import { Attachment } from 'svelte/attachments';
9
12
  */
10
13
 
11
- /**
12
- * How long to wait, in milliseconds, before the type-ahead search terms are reset.
13
- */
14
- const TYPE_AHEAD_TIMEOUT = 500;
15
14
  /**
16
15
  * CSS selector to retrieve the tree items.
17
16
  */
@@ -25,6 +24,12 @@ const ITEM_SELECTOR = '[role="treeitem"]';
25
24
  * @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
26
25
  */
27
26
  export class Tree {
27
+ /**
28
+ * Keystroke buffer for type-ahead.
29
+ * @type {TypeAhead}
30
+ */
31
+ #typeAhead = new TypeAhead();
32
+
28
33
  /**
29
34
  * Initialize a new `Tree` instance.
30
35
  * @param {HTMLElement} parent Parent element.
@@ -55,18 +60,6 @@ export class Tree {
55
60
  */
56
61
  this.anchor = undefined;
57
62
 
58
- /**
59
- * Currently accumulated type-ahead search terms.
60
- * @type {string}
61
- */
62
- this.typeAheadTerms = '';
63
-
64
- /**
65
- * Timer used to reset the type-ahead search terms.
66
- * @type {ReturnType<typeof globalThis.setTimeout> | undefined}
67
- */
68
- this.typeAheadTimer = undefined;
69
-
70
63
  /**
71
64
  * Whether {@link activate} has run.
72
65
  * @type {boolean}
@@ -79,26 +72,12 @@ export class Tree {
79
72
  */
80
73
  this.destroyed = false;
81
74
 
82
- // eslint-disable-next-line jsdoc/require-description
83
75
  /** @type {(event: MouseEvent) => void} */
84
- this._onClick = (event) => {
85
- this.activate();
86
- this.onClick(event);
87
- };
88
-
89
- // eslint-disable-next-line jsdoc/require-description
76
+ this._onClick = activateBefore(this, (event) => this.onClick(event));
90
77
  /** @type {(event: KeyboardEvent) => void} */
91
- this._onKeyDown = (event) => {
92
- this.activate();
93
- this.onKeyDown(event);
94
- };
95
-
96
- // eslint-disable-next-line jsdoc/require-description
78
+ this._onKeyDown = activateBefore(this, (event) => this.onKeyDown(event));
97
79
  /** @type {(event: FocusEvent) => void} */
98
- this._onFocusIn = (event) => {
99
- this.activate();
100
- this.onFocusIn(event);
101
- };
80
+ this._onFocusIn = activateBefore(this, (event) => this.onFocusIn(event));
102
81
 
103
82
  // The items can be added or removed at any time, e.g. when a subtree is lazily rendered
104
83
  this.observer = new globalThis.MutationObserver(() => {
@@ -114,16 +93,9 @@ export class Tree {
114
93
  parent.addEventListener('keydown', this._onKeyDown);
115
94
  parent.addEventListener('focusin', this._onFocusIn);
116
95
 
117
- // Wait a bit before the child components are mounted
118
- (async () => {
119
- await sleep(100);
120
-
121
- // The widget may have been unmounted in the meantime, and the observer set up by
122
- // `activate()` would then never be disconnected
123
- if (!this.destroyed) {
124
- this.activate();
125
- }
126
- })();
96
+ // Wait a bit before the child components are mounted. If the widget is unmounted in the
97
+ // meantime, the observer set up by `activate()` would never be disconnected.
98
+ activateLater(this, () => this.destroyed);
127
99
  }
128
100
 
129
101
  /**
@@ -394,11 +366,7 @@ export class Tree {
394
366
  * @param {HTMLElement} element Element to be scrolled into view.
395
367
  */
396
368
  scrollIntoView(element) {
397
- try {
398
- element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
399
- } catch {
400
- element.scrollIntoView(true);
401
- }
369
+ scrollIntoViewIfNeeded(element);
402
370
  }
403
371
 
404
372
  /**
@@ -473,10 +441,13 @@ export class Tree {
473
441
  }
474
442
 
475
443
  const { multi, anchor } = this;
444
+ const items = multi && range && anchor && anchor !== item ? this.visibleItems : [];
445
+ const anchorIndex = items.indexOf(/** @type {HTMLElement} */ (anchor));
476
446
 
477
- if (multi && range && anchor && anchor !== item) {
478
- const items = this.visibleItems;
479
- const indexes = [items.indexOf(anchor), items.indexOf(item)].sort((a, b) => a - b);
447
+ // An anchor that has been hidden in a collapsed parent, or removed, can’t start a range, so
448
+ // the item is selected on its own instead, becoming the new anchor
449
+ if (anchorIndex !== -1) {
450
+ const indexes = [anchorIndex, items.indexOf(item)].sort((a, b) => a - b);
480
451
 
481
452
  items.forEach((element, index) => {
482
453
  this.setSelected(
@@ -560,34 +531,22 @@ export class Tree {
560
531
  }
561
532
 
562
533
  /**
563
- * Move focus to the next item that matches the accumulated type-ahead search terms.
534
+ * Move focus to the next item that matches the accumulated type-ahead search terms. Typing the
535
+ * same character repeatedly cycles through the items starting with it.
564
536
  * @param {string} char Typed character.
565
537
  * @param {HTMLElement} currentItem Currently focused item.
566
538
  */
567
539
  typeAhead(char, currentItem) {
568
- globalThis.clearTimeout(this.typeAheadTimer);
569
-
570
- this.typeAheadTerms += char;
571
- this.typeAheadTimer = globalThis.setTimeout(() => {
572
- this.typeAheadTerms = '';
573
- }, TYPE_AHEAD_TIMEOUT);
574
-
575
- const terms = normalize(this.typeAheadTerms);
576
540
  const items = this.activeItems;
577
- const index = items.indexOf(currentItem);
578
541
 
579
- // Start the search right after the current item, so repeatedly typing the same character
580
- // cycles through the matches. Keep the current item first while the terms are being extended.
581
- const orderedItems = [
582
- ...(terms.length > 1 ? [currentItem] : []),
583
- ...items.slice(index + 1),
584
- ...items.slice(0, index + 1),
585
- ];
586
-
587
- const match = orderedItems.find((item) => normalize(this.getLabel(item)).startsWith(terms));
542
+ const index = findTypeAheadMatch(
543
+ items.map((item) => normalize(this.getLabel(item))),
544
+ this.#typeAhead.push(char),
545
+ items.indexOf(currentItem),
546
+ );
588
547
 
589
- if (match && match !== currentItem) {
590
- this.focusItem(match);
548
+ if (index !== -1 && items[index] !== currentItem) {
549
+ this.focusItem(items[index]);
591
550
  }
592
551
  }
593
552
 
@@ -668,8 +627,7 @@ export class Tree {
668
627
  const currentItem = target ?? this.currentItem ?? activeItems[0];
669
628
  const index = activeItems.indexOf(currentItem);
670
629
  // In RTL, the Left and Right arrow keys are swapped
671
- const forwardKey = isRTL() ? 'ArrowLeft' : 'ArrowRight';
672
- const backwardKey = isRTL() ? 'ArrowRight' : 'ArrowLeft';
630
+ const { prevKey: backwardKey, nextKey: forwardKey } = getArrowKeys('horizontal', isRTL());
673
631
 
674
632
  if (key === 'a' && (ctrlKey || metaKey)) {
675
633
  if (multi) {
@@ -787,7 +745,7 @@ export class Tree {
787
745
  */
788
746
  destroy() {
789
747
  this.destroyed = true;
790
- globalThis.clearTimeout(this.typeAheadTimer);
748
+ this.#typeAhead.reset();
791
749
  this.observer.disconnect();
792
750
  this.parent.removeEventListener('click', this._onClick);
793
751
  this.parent.removeEventListener('keydown', this._onKeyDown);
@@ -1,7 +1,3 @@
1
- /**
2
- * Type-ahead support shared by the composite widgets: the collapsed select-only combobox, and the
3
- * listbox and menu groups.
4
- */
5
1
  /**
6
2
  * How long, in milliseconds, successive keystrokes keep adding up to one type-ahead prefix.
7
3
  */
@@ -3,6 +3,8 @@
3
3
  * listbox and menu groups.
4
4
  */
5
5
 
6
+ import { fold } from './text.js';
7
+
6
8
  /**
7
9
  * How long, in milliseconds, successive keystrokes keep adding up to one type-ahead prefix.
8
10
  */
@@ -26,7 +28,7 @@ export class TypeAhead {
26
28
  */
27
29
  push(key) {
28
30
  globalThis.clearTimeout(this.#timer);
29
- this.#prefix += key.toLocaleLowerCase();
31
+ this.#prefix += fold(key);
30
32
  this.#timer = /** @type {number} */ (
31
33
  /** @type {unknown} */ (
32
34
  globalThis.setTimeout(() => {
@@ -0,0 +1,8 @@
1
+ export function activateBefore<E extends Event>(widget: ActivatableWidget, handler: (event: E) => void): (event: E) => void;
2
+ export function activateLater(widget: ActivatableWidget, isDestroyed: () => boolean): Promise<void>;
3
+ export type ActivatableWidget = {
4
+ /**
5
+ * Set up the members, unless they already are.
6
+ */
7
+ activate: () => void;
8
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Activation scaffolding shared by the composite widgets, `Group` and `Tree`, which set up their
3
+ * members only once the child components have had a chance to mount.
4
+ */
5
+
6
+ import { sleep } from '@sveltia/utils/misc';
7
+
8
+ /**
9
+ * @typedef {object} ActivatableWidget
10
+ * @property {() => void} activate Set up the members, unless they already are.
11
+ */
12
+
13
+ /**
14
+ * Create an event listener that activates the widget before handing the event on, so an event
15
+ * that comes before the delayed activation is not lost.
16
+ * @template {Event} E
17
+ * @param {ActivatableWidget} widget Widget.
18
+ * @param {(event: E) => void} handler Function that handles the event.
19
+ * @returns {(event: E) => void} Event listener.
20
+ */
21
+ export const activateBefore = (widget, handler) => (event) => {
22
+ widget.activate();
23
+ handler(event);
24
+ };
25
+
26
+ /**
27
+ * Activate the widget after a short delay, giving the relevant components time to mount, unless
28
+ * the widget has been destroyed in the meantime.
29
+ * @param {ActivatableWidget} widget Widget.
30
+ * @param {() => boolean} isDestroyed Function that tells whether the widget has been destroyed.
31
+ */
32
+ export const activateLater = async (widget, isDestroyed) => {
33
+ await sleep(100);
34
+
35
+ // The widget may have been unmounted in the meantime
36
+ if (!isDestroyed()) {
37
+ widget.activate();
38
+ }
39
+ };