@sveltia/ui 0.77.1 → 0.77.3

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 (37) hide show
  1. package/dist/components/calendar/calendar.d.ts +1 -1
  2. package/dist/components/calendar/calendar.js +12 -13
  3. package/dist/components/calendar/calendar.svelte +11 -7
  4. package/dist/components/checkbox/checkbox.svelte +1 -1
  5. package/dist/components/emoji/emoji-suggestions.svelte +1 -1
  6. package/dist/components/file/file-picker.svelte +4 -1
  7. package/dist/components/listbox/listbox.svelte +1 -1
  8. package/dist/components/progressbar/progressbar.svelte +6 -1
  9. package/dist/components/resizable-pane/resizable-handle.svelte +7 -0
  10. package/dist/components/scroll/infinite-scroll.svelte +14 -1
  11. package/dist/components/slider/slider.js +21 -3
  12. package/dist/components/slider/slider.svelte +4 -0
  13. package/dist/components/text-editor/code-editor.js +1 -1
  14. package/dist/components/text-editor/core.d.ts +2 -1
  15. package/dist/components/text-editor/core.js +110 -18
  16. package/dist/components/text-editor/lexical-root.svelte +27 -2
  17. package/dist/components/text-editor/lexical-root.svelte.d.ts +10 -0
  18. package/dist/components/text-editor/markdown.js +30 -11
  19. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  20. package/dist/components/text-editor/shiki/version.js +1 -1
  21. package/dist/components/text-editor/text-editor.svelte +3 -1
  22. package/dist/components/text-editor/text-editor.svelte.d.ts +4 -2
  23. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +14 -9
  24. package/dist/components/text-editor/toolbar/insert-link-button.svelte +15 -6
  25. package/dist/components/text-field/number-input.js +16 -3
  26. package/dist/components/text-field/number-input.svelte +7 -5
  27. package/dist/components/text-field/secret-input.svelte +5 -0
  28. package/dist/components/toast/toast.svelte +5 -0
  29. package/dist/components/util/app-shell.js +11 -3
  30. package/dist/components/util/popup.svelte +1 -1
  31. package/dist/services/group.svelte.d.ts +6 -0
  32. package/dist/services/group.svelte.js +69 -31
  33. package/dist/services/popup.svelte.js +18 -3
  34. package/dist/services/select.svelte.js +42 -5
  35. package/dist/services/tree.svelte.d.ts +5 -0
  36. package/dist/services/tree.svelte.js +13 -1
  37. package/package.json +2 -2
@@ -23,7 +23,8 @@
23
23
  * @typedef {object} Props
24
24
  * @property {string} [value] Input value.
25
25
  * @property {boolean} [flex] Make the text input container flexible.
26
- * @property {'ltr' | 'rtl' | 'auto'} [dir] The `dir` attribute on the `<textarea>` element.
26
+ * @property {'ltr' | 'rtl' | 'auto'} [dir] The `dir` attribute on the editable text box, both
27
+ * the rich text editor and the `<textarea>` element of the plain text mode.
27
28
  * @property {TextEditorMode[]} [modes] Enabled modes.
28
29
  * @property {TextEditorNodeType[]} [buttons] Enabled buttons.
29
30
  * @property {TextEditorComponent[]} [components] Editor components.
@@ -137,6 +138,7 @@
137
138
  <TextEditorToolbar {disabled} {readonly} />
138
139
  <LexicalRoot
139
140
  {...labelAttrs}
141
+ {dir}
140
142
  hidden={!editorStore.useRichText || hidden}
141
143
  {disabled}
142
144
  {readonly}
@@ -14,7 +14,8 @@ declare const TextEditor: import("svelte").Component<{
14
14
  */
15
15
  flex?: boolean | undefined;
16
16
  /**
17
- * The `dir` attribute on the `<textarea>` element.
17
+ * The `dir` attribute on the editable text box, both
18
+ * the rich text editor and the `<textarea>` element of the plain text mode.
18
19
  */
19
20
  dir?: "ltr" | "rtl" | "auto" | undefined;
20
21
  /**
@@ -96,7 +97,8 @@ type Props = {
96
97
  */
97
98
  flex?: boolean | undefined;
98
99
  /**
99
- * The `dir` attribute on the `<textarea>` element.
100
+ * The `dir` attribute on the editable text box, both
101
+ * the rich text editor and the `<textarea>` element of the plain text mode.
100
102
  */
101
103
  dir?: "ltr" | "rtl" | "auto" | undefined;
102
104
  /**
@@ -36,6 +36,16 @@
36
36
 
37
37
  let selectedLanguage = $state('plain');
38
38
 
39
+ // A code block can carry an alias, e.g. `js`, while the options are keyed by the canonical ID,
40
+ // e.g. `javascript`, so resolve the alias for the selector to find its option. A language that
41
+ // isn’t listed, e.g. `txt`, is shown as plain text; otherwise the selector would keep the label
42
+ // of the previous block, as it only updates the label when an option matches
43
+ const selectedKey = $derived(
44
+ codeLanguages.find(
45
+ ({ key, aliases }) => key === selectedLanguage || aliases.includes(selectedLanguage),
46
+ )?.key ?? 'plain',
47
+ );
48
+
39
49
  $effect(() => {
40
50
  void editorStore.selection.blockNodeKey;
41
51
 
@@ -57,9 +67,9 @@
57
67
  <Select
58
68
  {disabled}
59
69
  ariaLabel={_('_sui.text_editor.language')}
60
- value={selectedLanguage}
70
+ value={selectedKey}
61
71
  onChange={async ({ detail: { value: lang } }) => {
62
- if (!editorStore.editor || selectedLanguage === lang) {
72
+ if (!editorStore.editor || selectedKey === lang) {
63
73
  return;
64
74
  }
65
75
 
@@ -87,12 +97,7 @@
87
97
  }}
88
98
  >
89
99
  <Option label={_('_sui.text_editor.plain_text')} value="plain" dir="ltr" />
90
- {#each codeLanguages as { key, label, aliases } (key)}
91
- <Option
92
- {label}
93
- value={key}
94
- selected={key === selectedLanguage || aliases.includes(selectedLanguage)}
95
- dir="ltr"
96
- />
100
+ {#each codeLanguages as { key, label } (key)}
101
+ <Option {label} value={key} selected={key === selectedKey} dir="ltr" />
97
102
  {/each}
98
103
  </Select>
@@ -21,7 +21,7 @@
21
21
  import Icon from '../../icon/icon.svelte';
22
22
  import TextInput from '../../text-field/text-input.svelte';
23
23
  import { AVAILABLE_BUTTONS } from '../constants.js';
24
- import { focusEditor } from '../core.js';
24
+ import { focusEditor, isSafeLinkURL } from '../core.js';
25
25
 
26
26
  /**
27
27
  * @import { TextEditorStore } from '../../../typedefs';
@@ -45,6 +45,10 @@
45
45
  let hasAnchor = $state(false);
46
46
  let anchorURL = $state('');
47
47
  let anchorText = $state('');
48
+ /**
49
+ * Whether the URL can be inserted: not empty, and safe to link to.
50
+ */
51
+ const isValidURL = $derived(!!anchorURL.trim() && isSafeLinkURL(anchorURL));
48
52
 
49
53
  /**
50
54
  * Create a new link by showing a dialog to accept a URL and optionally text.
@@ -117,7 +121,7 @@
117
121
  * @param {KeyboardEvent} event `keydown` event.
118
122
  */
119
123
  const onInputKeyDown = (event) => {
120
- if (matchesShortcuts(event, 'Enter') && anchorURL) {
124
+ if (matchesShortcuts(event, 'Enter') && isValidURL) {
121
125
  openDialog = false;
122
126
  }
123
127
  };
@@ -167,8 +171,9 @@
167
171
 
168
172
  /**
169
173
  * Open the dialog with a keyboard shortcut: Accel+K.
174
+ * @returns {(() => void) | undefined} Function to unregister the command.
170
175
  */
171
- const _registerCommand = () => {
176
+ const _registerCommand = () =>
172
177
  editorStore.editor?.registerCommand(
173
178
  KEY_DOWN_COMMAND,
174
179
  (event) => {
@@ -181,12 +186,15 @@
181
186
  },
182
187
  COMMAND_PRIORITY_NORMAL,
183
188
  );
184
- };
185
189
 
186
190
  $effect(() => {
187
191
  if (editorStore.editor) {
188
- _registerCommand();
192
+ // Unregister on unmount, e.g. when the toolbar swaps the button out while the caret is in a
193
+ // code block, so the shortcut isn’t handled by a stale instance
194
+ return _registerCommand();
189
195
  }
196
+
197
+ return undefined;
190
198
  });
191
199
  </script>
192
200
 
@@ -210,7 +218,7 @@
210
218
  ? _('_sui.text_editor.insert_link')
211
219
  : _('_sui.text_editor.update_link')}
212
220
  bind:open={openDialog}
213
- okDisabled={!anchorURL}
221
+ okDisabled={!isValidURL}
214
222
  okLabel={dialogMode === 'create' ? _('_sui.insert') : _('_sui.update')}
215
223
  restoreFocus={false}
216
224
  onClose={(event) => {
@@ -224,6 +232,7 @@
224
232
  id="{id}-url"
225
233
  bind:value={anchorURL}
226
234
  flex
235
+ invalid={!!anchorURL.trim() && !isSafeLinkURL(anchorURL)}
227
236
  aria-label={_('_sui.text_editor.url')}
228
237
  onkeydown={(event) => {
229
238
  onInputKeyDown(event);
@@ -54,8 +54,9 @@ export const isAtMax = (inputValue, max) => typeof max === 'number' && toNumber(
54
54
  * @param {1 | -1} options.direction `1` to increase, `-1` to decrease.
55
55
  * @param {number} [options.min] Minimum allowed value.
56
56
  * @param {number} [options.max] Maximum allowed value.
57
- * @returns {string | undefined} New text for the field, or `undefined` if the field holds no
58
- * number or is already at the limit in that direction, in which case it’s left as is.
57
+ * @returns {string | undefined} New text for the field, clamped to the range, or `undefined` if
58
+ * the field holds no number or is already at the limit in that direction, in which case it’s left
59
+ * as is.
59
60
  */
60
61
  export const stepNumber = (inputValue, { step, direction, min, max }) => {
61
62
  if (Number.isNaN(Number(inputValue))) {
@@ -66,7 +67,19 @@ export const stepNumber = (inputValue, { step, direction, min, max }) => {
66
67
  return undefined;
67
68
  }
68
69
 
69
- return Number(toNumber(inputValue) + step * direction).toFixed(getMaximumFractionDigits(step));
70
+ const maximumFractionDigits = getMaximumFractionDigits(step);
71
+ const newValue = Number((toNumber(inputValue) + step * direction).toFixed(maximumFractionDigits));
72
+
73
+ // Stop at the limit rather than stepping past it, as the native `<input type="number">` does
74
+ if (typeof max === 'number' && newValue > max) {
75
+ return String(max);
76
+ }
77
+
78
+ if (typeof min === 'number' && newValue < min) {
79
+ return String(min);
80
+ }
81
+
82
+ return newValue.toFixed(maximumFractionDigits);
70
83
  };
71
84
 
72
85
  /**
@@ -69,11 +69,13 @@
69
69
  const isMax = $derived(isAtMax(inputValue, max));
70
70
 
71
71
  $effect(() => {
72
- const newInputValue = String(value ?? '');
72
+ const newValue = value;
73
73
 
74
+ // Only overwrite the field when it doesn’t already hold the value, so text that is still being
75
+ // typed, like `1.` or `-`, isn’t wiped out when it parses to the same number or no number
74
76
  untrack(() => {
75
- if (inputValue !== newInputValue) {
76
- inputValue = newInputValue;
77
+ if (parseNumber(inputValue) !== newValue) {
78
+ inputValue = String(newValue ?? '');
77
79
  }
78
80
  });
79
81
  });
@@ -126,7 +128,7 @@
126
128
  <div role="none" class="buttons">
127
129
  <Button
128
130
  iconic
129
- disabled={disabled || readonly || Number.isNaN(Number(value)) || isMax}
131
+ disabled={disabled || readonly || Number.isNaN(Number(inputValue)) || isMax}
130
132
  aria-label={_('_sui.number_input.increase')}
131
133
  aria-controls={id}
132
134
  onclick={() => {
@@ -143,7 +145,7 @@
143
145
  </Button>
144
146
  <Button
145
147
  iconic
146
- disabled={disabled || readonly || Number.isNaN(Number(value)) || isMin}
148
+ disabled={disabled || readonly || Number.isNaN(Number(inputValue)) || isMin}
147
149
  aria-label={_('_sui.number_input.decrease')}
148
150
  aria-controls={id}
149
151
  onclick={() => {
@@ -61,8 +61,13 @@
61
61
  class:show
62
62
  {hidden}
63
63
  >
64
+ <!-- The field is masked with CSS rather than `type="password"`, so opt out of the features that
65
+ would remember the secret, like autofill history and the keyboard’s learned words -->
64
66
  <TextInput
65
67
  dir="ltr"
68
+ autocomplete="off"
69
+ autocapitalize="off"
70
+ autocorrect="off"
66
71
  bind:element={inputElement}
67
72
  {...restProps}
68
73
  {id}
@@ -336,6 +336,11 @@
336
336
  )
337
337
  );
338
338
  }
339
+
340
+ // Stop the countdown when the toast is destroyed, or it would still hide the toast later
341
+ return () => {
342
+ globalThis.clearTimeout(timerId);
343
+ };
339
344
  });
340
345
  </script>
341
346
 
@@ -2,6 +2,11 @@
2
2
  * Helpers behind `<AppShell>`, kept apart from the component so they can be tested on their own.
3
3
  */
4
4
 
5
+ /**
6
+ * `<input>` types that hold editable text.
7
+ */
8
+ const TEXT_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
9
+
5
10
  /**
6
11
  * Whether the browser’s context menu should be allowed on the given element. The shell suppresses
7
12
  * it to feel more like a native app, except where it’s actually useful: on text fields and rich
@@ -19,10 +24,13 @@ export const shouldAllowContextMenu = (target) => {
19
24
  return false;
20
25
  }
21
26
 
22
- // Allow context menu on text inputs and contentEditable elements. Checking `maxLength` rules
23
- // out the input types that hold no text, such as a checkbox or a button.
27
+ // Allow context menu on text inputs and contentEditable elements. Only the input types that hold
28
+ // editable text qualify, not a checkbox or a button. (Every `<input>` has a `maxLength` property
29
+ // whatever its type, so that can’t tell them apart.)
24
30
  return (
25
- (target.matches('input, textarea') && 'maxLength' in target) ||
31
+ target instanceof HTMLTextAreaElement ||
32
+ // The `type` property is normalized: lowercased, and `text` for a missing or unknown type
33
+ (target instanceof HTMLInputElement && TEXT_INPUT_TYPES.includes(target.type)) ||
26
34
  /** @type {HTMLElement | null} */ (target.closest('[role="textbox"]'))?.contentEditable ===
27
35
  'true'
28
36
  );
@@ -252,7 +252,7 @@
252
252
  .content.listbox {
253
253
  border-width: var(--sui-listbox-border-width, 1px);
254
254
  border-style: var(--sui-listbox-border-style, solid);
255
- border-color: var(--sui-listbox-border-width, var(--sui-secondary-border-color));
255
+ border-color: var(--sui-listbox-border-color, var(--sui-secondary-border-color));
256
256
  border-radius: var(--sui-listbox-border-radius, 4px);
257
257
  padding: var(--sui-listbox-padding, 4px);
258
258
  }
@@ -143,6 +143,12 @@ export class Group {
143
143
  * @param {HTMLElement} newTarget Target element.
144
144
  */
145
145
  selectTarget(event: (MouseEvent | KeyboardEvent), newTarget: HTMLElement): void;
146
+ /**
147
+ * Scroll the given elements into view after a short delay, replacing any pending scroll, so a
148
+ * key held down doesn’t queue a scroll for every member it passes and jump back to an old one.
149
+ * @param {HTMLElement[]} elements Elements to scroll into view.
150
+ */
151
+ scrollIntoViewLater(elements: HTMLElement[]): void;
146
152
  /**
147
153
  * Handle the `click` event on the widget.
148
154
  * @param {MouseEvent} event `click` event.
@@ -1,6 +1,7 @@
1
1
  import { isRTL } from '@sveltia/i18n';
2
2
  import { generateElementId } from '@sveltia/utils/element';
3
3
  import { sleep } from '@sveltia/utils/misc';
4
+ import { untrack } from 'svelte';
4
5
  import { getSelectedItemDetail } from './select.svelte.js';
5
6
  import { findTypeAheadMatch, TypeAhead } from './type-ahead.js';
6
7
 
@@ -197,6 +198,18 @@ export class Group {
197
198
  */
198
199
  activated = false;
199
200
 
201
+ /**
202
+ * Whether {@link destroy} has run, so a pending {@link activate} must not attach the listeners.
203
+ * @type {boolean}
204
+ */
205
+ #destroyed = false;
206
+
207
+ /**
208
+ * Timer for the pending {@link scrollIntoViewLater} call, if any.
209
+ * @type {ReturnType<typeof setTimeout> | undefined}
210
+ */
211
+ #scrollTimer = undefined;
212
+
200
213
  /**
201
214
  * Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
202
215
  * @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
@@ -342,7 +355,12 @@ export class Group {
342
355
  // Wait a bit before the relevant components, including the `aria-controls` target are mounted
343
356
  (async () => {
344
357
  await sleep(100);
345
- this.activate();
358
+
359
+ // The widget may have been unmounted in the meantime, and the listeners added by `activate()`
360
+ // would then never be removed
361
+ if (!this.#destroyed) {
362
+ this.activate();
363
+ }
346
364
  })();
347
365
  }
348
366
 
@@ -360,11 +378,10 @@ export class Group {
360
378
  element.getAttribute(this.childSelectedAttr) === 'true' ||
361
379
  (defaultSelected ? element === defaultSelected : this.selectFirst && index === 0);
362
380
 
363
- const controlTarget = this.controlsPanel
364
- ? /** @type {HTMLElement | null} */ (
365
- document.querySelector(`#${element.getAttribute('aria-controls')}`)
366
- )
367
- : null;
381
+ const controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
382
+ // Looked up by ID rather than with a selector, which would throw on an ID that isn’t a valid
383
+ // CSS identifier, such as one starting with a digit
384
+ const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
368
385
 
369
386
  element.id ||= `${this.id}-item-${index + 1}`;
370
387
  element.setAttribute(this.childSelectedAttr, String(isSelected));
@@ -697,6 +714,11 @@ export class Group {
697
714
  * @type {boolean}
698
715
  */
699
716
  let targetAffected = false;
717
+ /**
718
+ * Elements to be scrolled into view: the target and the panel it controls, if any.
719
+ * @type {HTMLElement[]}
720
+ */
721
+ const scrollTargets = [];
700
722
 
701
723
  this.activeMembers.forEach((element) => {
702
724
  // Reading the role once and comparing it is markedly cheaper than putting every member
@@ -765,33 +787,20 @@ export class Group {
765
787
  controlTarget.setAttribute('aria-hidden', String(!isTarget));
766
788
 
767
789
  if (isTarget) {
768
- globalThis.setTimeout(() => {
769
- try {
770
- controlTarget.scrollIntoView({
771
- block: 'nearest',
772
- inline: 'nearest',
773
- behavior: 'auto',
774
- });
775
- } catch {
776
- controlTarget.scrollIntoView(true);
777
- }
778
- }, 300);
790
+ scrollTargets.push(controlTarget);
779
791
  }
780
792
  }
781
793
 
782
794
  if (isTarget) {
783
795
  this.parent.setAttribute('aria-activedescendant', element.id);
784
-
785
- globalThis.setTimeout(() => {
786
- try {
787
- element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
788
- } catch {
789
- element.scrollIntoView(true);
790
- }
791
- }, 300);
796
+ scrollTargets.push(element);
792
797
  }
793
798
  });
794
799
 
800
+ if (scrollTargets.length) {
801
+ this.scrollIntoViewLater(scrollTargets);
802
+ }
803
+
795
804
  if (this.focusChild) {
796
805
  // Done right away rather than on the next frame: a key that repeats, or a second press
797
806
  // before the frame, has to start from the member that was just reached, which it reads
@@ -811,6 +820,27 @@ export class Group {
811
820
  );
812
821
  }
813
822
 
823
+ /**
824
+ * Scroll the given elements into view after a short delay, replacing any pending scroll, so a
825
+ * key held down doesn’t queue a scroll for every member it passes and jump back to an old one.
826
+ * @param {HTMLElement[]} elements Elements to scroll into view.
827
+ */
828
+ scrollIntoViewLater(elements) {
829
+ globalThis.clearTimeout(this.#scrollTimer);
830
+
831
+ this.#scrollTimer = globalThis.setTimeout(() => {
832
+ this.#scrollTimer = undefined;
833
+
834
+ elements.forEach((element) => {
835
+ try {
836
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
837
+ } catch {
838
+ element.scrollIntoView(true);
839
+ }
840
+ });
841
+ }, 300);
842
+ }
843
+
814
844
  /**
815
845
  * Handle the `click` event on the widget.
816
846
  * @param {MouseEvent} event `click` event.
@@ -1031,13 +1061,16 @@ export class Group {
1031
1061
  newTarget = allMembers[Math.min(index + colCount, lastIndex)];
1032
1062
  }
1033
1063
 
1034
- // In RTL, ArrowLeft moves right (next), ArrowRight moves left (previous)
1035
- if (key === 'ArrowLeft' && index > 0) {
1036
- newTarget = allMembers[index + (_isRTL ? 1 : -1)];
1064
+ // In RTL, ArrowLeft moves to the next member, ArrowRight to the previous one
1065
+ const prevKey = _isRTL ? 'ArrowRight' : 'ArrowLeft';
1066
+ const nextKey = _isRTL ? 'ArrowLeft' : 'ArrowRight';
1067
+
1068
+ if (key === prevKey && index > 0) {
1069
+ newTarget = allMembers[index - 1];
1037
1070
  }
1038
1071
 
1039
- if (key === 'ArrowRight' && index !== -1 && index < lastIndex) {
1040
- newTarget = allMembers[index + (_isRTL ? -1 : 1)];
1072
+ if (key === nextKey && index !== -1 && index < lastIndex) {
1073
+ newTarget = allMembers[index + 1];
1041
1074
  }
1042
1075
 
1043
1076
  if (newTarget?.matches('[aria-disabled="true"], [aria-hidden="true"]')) {
@@ -1089,6 +1122,8 @@ export class Group {
1089
1122
  * Clean up event listeners.
1090
1123
  */
1091
1124
  destroy() {
1125
+ this.#destroyed = true;
1126
+ globalThis.clearTimeout(this.#scrollTimer);
1092
1127
  this.#typeAhead.reset();
1093
1128
  this.observer.disconnect();
1094
1129
  this.parent.removeEventListener('click', this._onClick);
@@ -1134,7 +1169,10 @@ export class Group {
1134
1169
  */
1135
1170
  export const activateGroup = (paramsOrGetter) => (parent) => {
1136
1171
  const isGetter = typeof paramsOrGetter === 'function';
1137
- const initialParams = isGetter ? paramsOrGetter() : paramsOrGetter;
1172
+ // Read the params without tracking them: attachments run in an effect, so a tracked read would
1173
+ // destroy and recreate the whole group whenever they change, e.g. on every search keystroke.
1174
+ // Changes are handled by `onUpdate()` below instead.
1175
+ const initialParams = isGetter ? untrack(() => paramsOrGetter()) : paramsOrGetter;
1138
1176
  const group = new Group(/** @type {HTMLElement} */ (parent), initialParams);
1139
1177
 
1140
1178
  /* v8 ignore next 4 */
@@ -86,6 +86,13 @@ class Popup {
86
86
  */
87
87
  #removeEventListeners = undefined;
88
88
 
89
+ /**
90
+ * Functions that remove the event listeners added to the {@link anchorElement}, which may outlive
91
+ * this instance.
92
+ * @type {(() => void)[]}
93
+ */
94
+ #removeAnchorListeners = [];
95
+
89
96
  /**
90
97
  * Initialize a new `Popup` instance. Note that the `popupElement` is optional, because the
91
98
  * element is typically mounted only while the popup is open. Use {@link attachPopupElement} to
@@ -105,13 +112,13 @@ class Popup {
105
112
 
106
113
  this.anchorElement.setAttribute('aria-expanded', 'false');
107
114
 
108
- on(anchorElement, 'click', () => {
115
+ const removeClickListener = on(anchorElement, 'click', () => {
109
116
  if (!this.isDisabled && !this.isReadOnly) {
110
117
  this.open = !this.open;
111
118
  }
112
119
  });
113
120
 
114
- on(anchorElement, 'keydown', (event) => {
121
+ const removeKeyDownListener = on(anchorElement, 'keydown', (event) => {
115
122
  const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
116
123
  const hasModifier = shiftKey || altKey || ctrlKey || metaKey;
117
124
 
@@ -145,12 +152,18 @@ class Popup {
145
152
  }
146
153
  });
147
154
 
148
- on(anchorElement, 'transitionstart', () => {
155
+ const removeTransitionStartListener = on(anchorElement, 'transitionstart', () => {
149
156
  if (this.anchorElement.closest('.hiding, .hidden, [hidden]')) {
150
157
  this.hideImmediately();
151
158
  }
152
159
  });
153
160
 
161
+ this.#removeAnchorListeners = [
162
+ removeClickListener,
163
+ removeKeyDownListener,
164
+ removeTransitionStartListener,
165
+ ];
166
+
154
167
  this.intersectionObserver = new IntersectionObserver(([entry]) => {
155
168
  if (!entry.isIntersecting && this.open) {
156
169
  this.hideImmediately();
@@ -514,6 +527,8 @@ class Popup {
514
527
  */
515
528
  destroy() {
516
529
  this.detachPopupElement();
530
+ this.#removeAnchorListeners.forEach((removeListener) => removeListener());
531
+ this.#removeAnchorListeners = [];
517
532
  this.intersectionObserver?.disconnect();
518
533
  this.resizeObserver?.disconnect();
519
534
  this.viewportResizeObserver?.disconnect();
@@ -29,11 +29,46 @@ export class OptionRegistry {
29
29
  expanded = $state(false);
30
30
 
31
31
  /**
32
- * Registered options. This is a raw state so that the entries, which expose their properties as
33
- * getters onto the `<Option>`’s props, are not wrapped in a reactive proxy.
32
+ * Registered options. This is a plain set rather than a state, so that registering an option
33
+ * doesn’t copy the whole list, which would make mounting many options quadratic. The entries,
34
+ * which expose their properties as getters onto the `<Option>`’s props, are not wrapped in a
35
+ * reactive proxy either. Changes are signalled through {@link #version}.
36
+ * @type {Set<OptionEntry>}
37
+ */
38
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Signalled manually, see above
39
+ #entrySet = new Set();
40
+
41
+ /**
42
+ * Incremented whenever an option is registered or unregistered, to notify the readers.
43
+ * @type {number}
44
+ */
45
+ #version = $state(0);
46
+
47
+ /**
48
+ * Array snapshot of {@link #entrySet}, rebuilt lazily after a change.
34
49
  * @type {OptionEntry[]}
35
50
  */
36
- #entries = $state.raw([]);
51
+ #snapshot = [];
52
+
53
+ /**
54
+ * Value of {@link #version} when {@link #snapshot} was last rebuilt.
55
+ * @type {number}
56
+ */
57
+ #snapshotVersion = -1;
58
+
59
+ /**
60
+ * Registered options, in registration order. The returned array must not be mutated.
61
+ * @returns {OptionEntry[]} Entries.
62
+ */
63
+ get #entries() {
64
+ // Reading `#version` also subscribes the caller to changes
65
+ if (this.#snapshotVersion !== this.#version) {
66
+ this.#snapshot = [...this.#entrySet];
67
+ this.#snapshotVersion = this.#version;
68
+ }
69
+
70
+ return this.#snapshot;
71
+ }
37
72
 
38
73
  /**
39
74
  * Number of registered options.
@@ -65,10 +100,12 @@ export class OptionRegistry {
65
100
  * @returns {() => void} Function to unregister the option.
66
101
  */
67
102
  register(entry) {
68
- this.#entries = [...this.#entries, entry];
103
+ this.#entrySet.add(entry);
104
+ this.#version += 1;
69
105
 
70
106
  return () => {
71
- this.#entries = this.#entries.filter((item) => item !== entry);
107
+ this.#entrySet.delete(entry);
108
+ this.#version += 1;
72
109
  };
73
110
  }
74
111
 
@@ -45,6 +45,11 @@ export class Tree {
45
45
  * @type {ReturnType<typeof globalThis.setTimeout> | undefined}
46
46
  */
47
47
  typeAheadTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
48
+ /**
49
+ * Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
50
+ * @type {boolean}
51
+ */
52
+ destroyed: boolean;
48
53
  /** @type {(event: MouseEvent) => void} */
49
54
  _onClick: (event: MouseEvent) => void;
50
55
  /** @type {(event: KeyboardEvent) => void} */
@@ -67,6 +67,12 @@ export class Tree {
67
67
  */
68
68
  this.typeAheadTimer = undefined;
69
69
 
70
+ /**
71
+ * Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
72
+ * @type {boolean}
73
+ */
74
+ this.destroyed = false;
75
+
70
76
  // eslint-disable-next-line jsdoc/require-description
71
77
  /** @type {(event: MouseEvent) => void} */
72
78
  this._onClick = (event) => {
@@ -96,7 +102,12 @@ export class Tree {
96
102
  // Wait a bit before the child components are mounted
97
103
  (async () => {
98
104
  await sleep(100);
99
- this.activate();
105
+
106
+ // The widget may have been unmounted in the meantime, and the listeners and the observer set
107
+ // up by `activate()` would then never be removed
108
+ if (!this.destroyed) {
109
+ this.activate();
110
+ }
100
111
  })();
101
112
  }
102
113
 
@@ -759,6 +770,7 @@ export class Tree {
759
770
  * Clean up event listeners.
760
771
  */
761
772
  destroy() {
773
+ this.destroyed = true;
762
774
  globalThis.clearTimeout(this.typeAheadTimer);
763
775
  this.observer.disconnect();
764
776
  this.parent.removeEventListener('click', this._onClick);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.77.1",
3
+ "version": "0.77.3",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,7 +57,7 @@
57
57
  "@shikijs/langs": "4.4.3",
58
58
  "@shikijs/themes": "4.4.3",
59
59
  "@sveltia/i18n": "^1.4.0",
60
- "@sveltia/utils": "^0.12.1",
60
+ "@sveltia/utils": "^0.12.2",
61
61
  "lexical": "^0.51.0"
62
62
  },
63
63
  "devDependencies": {