@sveltia/ui 0.77.3 → 0.78.0

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 (39) hide show
  1. package/dist/components/dialog/dialog.svelte +7 -4
  2. package/dist/components/text-editor/constants.d.ts +5 -0
  3. package/dist/components/text-editor/constants.js +6 -0
  4. package/dist/components/text-editor/core.d.ts +6 -2
  5. package/dist/components/text-editor/core.js +316 -46
  6. package/dist/components/text-editor/lexical-root.svelte +39 -3
  7. package/dist/components/text-editor/raw-markdown.d.ts +90 -0
  8. package/dist/components/text-editor/raw-markdown.js +471 -0
  9. package/dist/components/text-editor/shiki/facade.d.ts +4 -0
  10. package/dist/components/text-editor/shiki/facade.js +62 -0
  11. package/dist/components/text-editor/shiki/theme.d.ts +1 -0
  12. package/dist/components/text-editor/shiki/theme.js +31 -27
  13. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  14. package/dist/components/text-editor/shiki/version.js +1 -1
  15. package/dist/components/text-editor/store.svelte.js +39 -1
  16. package/dist/components/text-editor/text-editor.svelte +137 -1
  17. package/dist/components/text-editor/text-editor.svelte.d.ts +19 -3
  18. package/dist/components/text-editor/toolbar/component-editor.svelte +96 -0
  19. package/dist/components/text-editor/toolbar/component-editor.svelte.d.ts +35 -0
  20. package/dist/components/text-editor/toolbar/format-text-button.svelte +69 -9
  21. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte +92 -0
  22. package/dist/components/text-editor/toolbar/insert-component-dialog.svelte.d.ts +30 -0
  23. package/dist/components/text-editor/toolbar/insert-item-button.svelte +13 -2
  24. package/dist/components/text-editor/toolbar/insert-link-button.svelte +89 -10
  25. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +21 -2
  26. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -4
  27. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +13 -0
  28. package/dist/components/text-field/text-area.svelte +119 -4
  29. package/dist/components/text-field/text-area.svelte.d.ts +17 -0
  30. package/dist/components/util/font-links.svelte +1 -1
  31. package/dist/components/util/fonts.js +1 -1
  32. package/dist/self-hosted/fonts/material-symbols-outlined-latin-wght-normal.woff2 +0 -0
  33. package/dist/services/group.svelte.d.ts +1 -1
  34. package/dist/services/group.svelte.js +14 -6
  35. package/dist/services/tree.svelte.d.ts +6 -1
  36. package/dist/services/tree.svelte.js +23 -7
  37. package/dist/typedefs.d.ts +49 -1
  38. package/dist/typedefs.js +22 -1
  39. package/package.json +2 -2
@@ -3,6 +3,7 @@
3
3
  import { $getNearestNodeOfType as getNearestNodeOfType } from '@lexical/utils';
4
4
  import { _ } from '@sveltia/i18n';
5
5
  import { isMac, matchesShortcuts } from '@sveltia/utils/events';
6
+ import { isURL } from '@sveltia/utils/string';
6
7
  import {
7
8
  COMMAND_PRIORITY_NORMAL,
8
9
  KEY_DOWN_COMMAND,
@@ -22,6 +23,12 @@
22
23
  import TextInput from '../../text-field/text-input.svelte';
23
24
  import { AVAILABLE_BUTTONS } from '../constants.js';
24
25
  import { focusEditor, isSafeLinkURL } from '../core.js';
26
+ import {
27
+ applyRawTextEdit,
28
+ getRawTextState,
29
+ insertLink,
30
+ isRawTextEditable,
31
+ } from '../raw-markdown.js';
25
32
 
26
33
  /**
27
34
  * @import { TextEditorStore } from '../../../typedefs';
@@ -50,17 +57,39 @@
50
57
  */
51
58
  const isValidURL = $derived(!!anchorURL.trim() && isSafeLinkURL(anchorURL));
52
59
 
60
+ /**
61
+ * Open the dialog to create a new link from the given selected text.
62
+ * @param {string} textContent Selected text.
63
+ */
64
+ const openCreateDialog = (textContent) => {
65
+ // Prefill the URL field with the selected text only if it’s a URL that can be linked to.
66
+ // Otherwise, it’s just the link text, like a word or phrase.
67
+ anchorURL = isURL(textContent) && isSafeLinkURL(textContent) ? textContent : '';
68
+ hasAnchor = !!textContent;
69
+ dialogMode = 'create';
70
+ openDialog = true;
71
+ };
72
+
53
73
  /**
54
74
  * Create a new link by showing a dialog to accept a URL and optionally text.
55
75
  */
56
76
  const createLink = () => {
57
- editorStore.editor?.getEditorState().read(() => {
58
- const textContent = getTextContent().trim();
77
+ const { textArea, useRichText } = editorStore;
78
+
79
+ if (!useRichText) {
80
+ // The button is only enabled while the `<textarea>` is there
81
+ /* v8 ignore else */
82
+ if (textArea) {
83
+ const { value, start, end } = getRawTextState(textArea);
59
84
 
60
- anchorURL = textContent;
61
- hasAnchor = !!textContent;
62
- dialogMode = 'create';
63
- openDialog = true;
85
+ openCreateDialog(value.slice(start, end).trim());
86
+ }
87
+
88
+ return;
89
+ }
90
+
91
+ editorStore.editor?.getEditorState().read(() => {
92
+ openCreateDialog(getTextContent().trim());
64
93
  });
65
94
  };
66
95
 
@@ -109,7 +138,8 @@
109
138
  * create a new link.
110
139
  */
111
140
  const onButtonClick = () => {
112
- if (selectionTypeMatches) {
141
+ // Links are not detected in the plain text mode, so a new one is always created there
142
+ if (selectionTypeMatches && editorStore.useRichText) {
113
143
  updateLink();
114
144
  } else {
115
145
  createLink();
@@ -132,6 +162,31 @@
132
162
  * @see https://github.com/facebook/lexical/discussions/3013
133
163
  */
134
164
  const onDialogClose = async (event) => {
165
+ const { textArea, useRichText } = editorStore;
166
+
167
+ if (!useRichText) {
168
+ // The dialog can only be opened from the button, which needs the `<textarea>`
169
+ /* v8 ignore else */
170
+ if (textArea) {
171
+ if (event.detail.returnValue !== 'cancel') {
172
+ applyRawTextEdit(
173
+ textArea,
174
+ insertLink(getRawTextState(textArea), {
175
+ url: anchorURL.trim(),
176
+ text: hasAnchor ? undefined : anchorText,
177
+ }),
178
+ );
179
+ } else {
180
+ textArea.focus();
181
+ }
182
+ }
183
+
184
+ anchorURL = '';
185
+ anchorText = '';
186
+
187
+ return;
188
+ }
189
+
135
190
  if (event.detail.returnValue !== 'cancel' && dialogMode !== 'remove') {
136
191
  // The dialog can only be opened from the button, which needs the editor
137
192
  /* v8 ignore next */
@@ -187,6 +242,31 @@
187
242
  COMMAND_PRIORITY_NORMAL,
188
243
  );
189
244
 
245
+ $effect(() => {
246
+ const { textArea } = editorStore;
247
+
248
+ if (!textArea) {
249
+ return undefined;
250
+ }
251
+
252
+ /**
253
+ * Handle the keyboard shortcut in the plain text mode.
254
+ * @param {KeyboardEvent} event `keydown` event.
255
+ */
256
+ const onKeyDown = (event) => {
257
+ if (isRawTextEditable(textArea) && matchesShortcuts(event, isMac() ? 'Meta+K' : 'Ctrl+K')) {
258
+ event.preventDefault();
259
+ onButtonClick();
260
+ }
261
+ };
262
+
263
+ textArea.addEventListener('keydown', onKeyDown);
264
+
265
+ return () => {
266
+ textArea.removeEventListener('keydown', onKeyDown);
267
+ };
268
+ });
269
+
190
270
  $effect(() => {
191
271
  if (editorStore.editor) {
192
272
  // Unregister on unmount, e.g. when the toolbar swaps the button out while the caret is in a
@@ -201,9 +281,8 @@
201
281
  <Button
202
282
  iconic
203
283
  aria-label={_(`_sui.text_editor.${AVAILABLE_BUTTONS[type].labelKey}`)}
204
- aria-controls={`${editorStore.editorId}-lexical-root`}
205
- disabled={!editorStore.useRichText}
206
- pressed={selectionTypeMatches}
284
+ aria-controls={editorStore.controlId}
285
+ pressed={selectionTypeMatches && editorStore.useRichText}
207
286
  onclick={() => {
208
287
  onButtonClick();
209
288
  }}
@@ -9,6 +9,7 @@
9
9
  import MenuButton from '../../menu/menu-button.svelte';
10
10
  import MenuItem from '../../menu/menu-item.svelte';
11
11
  import Menu from '../../menu/menu.svelte';
12
+ import InsertComponentDialog from './insert-component-dialog.svelte';
12
13
 
13
14
  /**
14
15
  * @import { TextEditorComponent, TextEditorStore } from '../../../typedefs';
@@ -28,18 +29,34 @@
28
29
 
29
30
  /** @type {TextEditorStore} */
30
31
  const editorStore = getContext('editorStore');
32
+
33
+ /**
34
+ * Component to insert with the dialog in the plain text mode.
35
+ * @type {TextEditorComponent | undefined}
36
+ */
37
+ let dialogComponent = $state();
38
+ let openDialog = $state(false);
31
39
  </script>
32
40
 
33
- <MenuButton disabled={!editorStore.useRichText} label={_('_sui.insert')}>
41
+ <MenuButton label={_('_sui.insert')}>
34
42
  {#snippet endIcon()}
35
43
  <Icon name="arrow_drop_down" class="small-arrow" />
36
44
  {/snippet}
37
45
  {#snippet popup()}
38
46
  <Menu>
39
- {#each components as { id, label, icon, createNode } (id)}
47
+ {#each components as component (component.id)}
48
+ {@const { label, icon, createNode } = component}
40
49
  <MenuItem
41
50
  {label}
42
51
  onclick={() => {
52
+ // The plain text mode needs a dialog to fill in the component’s fields
53
+ if (!editorStore.useRichText) {
54
+ dialogComponent = component;
55
+ openDialog = true;
56
+
57
+ return;
58
+ }
59
+
43
60
  editorStore.editor?.update(() => {
44
61
  // Add an additional paragraph for easier editing
45
62
  insertNodes([createNode(), createParagraphNode()]);
@@ -56,3 +73,5 @@
56
73
  </Menu>
57
74
  {/snippet}
58
75
  </MenuButton>
76
+
77
+ <InsertComponentDialog component={dialogComponent} bind:open={openDialog} />
@@ -80,9 +80,8 @@
80
80
  <ToolbarWrapper disabled={disabled || readonly} ariaLabel={_('_sui.text_editor.text_editor')}>
81
81
  {#if blockLevelButtons.length > 1}
82
82
  <MenuButton
83
- disabled={!editorStore.useRichText}
84
83
  aria-label={_('_sui.text_editor.show_text_style_options')}
85
- aria-controls={`${editorStore.editorId}-lexical-root`}
84
+ aria-controls={editorStore.controlId}
86
85
  >
87
86
  {#snippet startIcon()}
88
87
  <Icon
@@ -98,9 +97,10 @@
98
97
  {/snippet}
99
98
  </MenuButton>
100
99
  {/if}
101
- {#if editorStore.selection.blockType === 'code-block'}
100
+ <!-- The language switcher only works with the rich text editor -->
101
+ {#if editorStore.useRichText && editorStore.selection.blockType === 'code-block'}
102
102
  <Divider orientation="vertical" />
103
- <CodeLanguageSwitcher disabled={!editorStore.useRichText} />
103
+ <CodeLanguageSwitcher />
104
104
  {:else}
105
105
  {#if inlineLevelButtons.length}
106
106
  <Divider orientation="vertical" />
@@ -16,6 +16,7 @@
16
16
  import MenuItemCheckbox from '../../menu/menu-item-checkbox.svelte';
17
17
  import { AVAILABLE_BUTTONS } from '../constants.js';
18
18
  import { focusEditor } from '../core.js';
19
+ import { applyRawTextEdit, getRawTextState, setBlockType } from '../raw-markdown.js';
19
20
 
20
21
  /**
21
22
  * @import { TextEditorBlockType, TextEditorStore } from '../../../typedefs';
@@ -44,6 +45,18 @@
44
45
  * Change the current selection’s type to {@link type}.
45
46
  */
46
47
  const changeBlockType = async () => {
48
+ if (!editorStore.useRichText) {
49
+ const { textArea } = editorStore;
50
+
51
+ // The item is only clickable while the `<textarea>` is there
52
+ /* v8 ignore else */
53
+ if (textArea) {
54
+ applyRawTextEdit(textArea, setBlockType(getRawTextState(textArea), type));
55
+ }
56
+
57
+ return;
58
+ }
59
+
47
60
  // The item is only clickable while the editor is there
48
61
  /* v8 ignore next */
49
62
  if (!editorStore.editor) {
@@ -10,7 +10,7 @@
10
10
 
11
11
  /**
12
12
  * @import { Snippet } from 'svelte';
13
- * @import { CommonEventHandlers, InputEventHandlers } from '../../typedefs';
13
+ * @import { CommonEventHandlers, HighlightedToken, InputEventHandlers } from '../../typedefs';
14
14
  */
15
15
 
16
16
  /**
@@ -24,6 +24,11 @@
24
24
  * content.
25
25
  * @property {boolean} [useEmojiAutocomplete] Whether to autocomplete emojis when the user
26
26
  * types a shortcode, like `:smi`.
27
+ * @property {(value: string) => HighlightedToken[][] | undefined} [highlight] Function to
28
+ * tokenize the value for syntax highlighting, returning one array of tokens per line, or
29
+ * `undefined` to show the text as is, for example while a grammar is still loading. The tokens
30
+ * are painted behind the text, which is made transparent, so the `<textarea>` keeps working as
31
+ * usual. Only styles that keep the text width can be applied, so the glyphs stay aligned.
27
32
  * @property {string} [class] The `class` attribute on the wrapper element.
28
33
  * @property {boolean} [hidden] Whether to hide the widget.
29
34
  * @property {boolean} [disabled] Whether to disable the widget. An alias of the `aria-disabled`
@@ -49,6 +54,7 @@
49
54
  name = undefined,
50
55
  autoResize = false,
51
56
  useEmojiAutocomplete = false,
57
+ highlight = undefined,
52
58
  class: className,
53
59
  hidden = false,
54
60
  disabled = false,
@@ -67,6 +73,51 @@
67
73
  * @type {string}
68
74
  */
69
75
  const cloneValue = $derived(`${value}\n`);
76
+ /**
77
+ * Syntax-highlighted tokens of the value, flattened with line breaks in between, plus a trailing
78
+ * one like {@link cloneValue}. They’re only used when they add up to the value exactly, since
79
+ * stale tokens would show text different from what’s being edited.
80
+ * @type {HighlightedToken[] | undefined}
81
+ */
82
+ const highlightedTokens = $derived.by(() => {
83
+ const lines = highlight?.(value ?? '');
84
+
85
+ if (!lines) {
86
+ return undefined;
87
+ }
88
+
89
+ const tokens = lines.flatMap((line) => [...line, { content: '\n' }]);
90
+
91
+ return tokens.map(({ content }) => content).join('') === cloneValue ? tokens : undefined;
92
+ });
93
+ /** A reference to the clone element. */
94
+ let cloneElement = $state();
95
+
96
+ $effect(() => {
97
+ // Keep the highlighted text scrolled along with the `<textarea>`. It never scrolls when it’s
98
+ // auto-resized
99
+ if (!element || !cloneElement || autoResize) {
100
+ return undefined;
101
+ }
102
+
103
+ const textarea = element;
104
+ const clone = cloneElement;
105
+
106
+ /**
107
+ * Sync the scroll position.
108
+ */
109
+ const onScroll = () => {
110
+ clone.scrollTop = textarea.scrollTop;
111
+ clone.scrollLeft = textarea.scrollLeft;
112
+ };
113
+
114
+ onScroll();
115
+ textarea.addEventListener('scroll', onScroll);
116
+
117
+ return () => {
118
+ textarea.removeEventListener('scroll', onScroll);
119
+ };
120
+ });
70
121
  </script>
71
122
 
72
123
  <div
@@ -75,6 +126,7 @@
75
126
  class:flex
76
127
  class:disabled
77
128
  class:readonly
129
+ class:highlighted={!!highlightedTokens}
78
130
  {hidden}
79
131
  >
80
132
  <textarea
@@ -91,8 +143,24 @@
91
143
  aria-required={required}
92
144
  aria-invalid={invalid}
93
145
  class:auto-resize={autoResize}></textarea>
94
- {#if autoResize}
95
- <div class="clone" aria-hidden="true" {dir}>{cloneValue}</div>
146
+ {#if highlightedTokens}
147
+ <!-- Line breaks are only allowed within tags, as any whitespace would end up in the text -->
148
+ <!-- prettier-ignore -->
149
+ <div
150
+ bind:this={cloneElement}
151
+ class="clone"
152
+ class:auto-resize={autoResize}
153
+ aria-hidden="true"
154
+ {dir}
155
+ >{#each highlightedTokens as token, index (index)}<span
156
+ style:color={token.color}
157
+ class:bold={token.bold}
158
+ class:underline={token.underline}
159
+ class:strikethrough={token.strikethrough}
160
+ >{token.content}</span
161
+ >{/each}</div>
162
+ {:else if autoResize}
163
+ <div class="clone auto-resize" aria-hidden="true" {dir}>{cloneValue}</div>
96
164
  {/if}
97
165
  {#if useEmojiAutocomplete && !disabled && !readonly}
98
166
  <EmojiAutocomplete {element} />
@@ -100,6 +168,7 @@
100
168
  </div>
101
169
 
102
170
  <style>.text-area {
171
+ position: relative;
103
172
  display: inline-grid;
104
173
  margin: var(--sui-focus-ring-width);
105
174
  min-width: var(--sui-textbox-multiline-min-width);
@@ -145,7 +214,7 @@
145
214
  color: var(--sui-textbox-foreground-color-focus, var(--sui-textbox-foreground-color));
146
215
  background-color: var(--sui-textbox-background-color-focus, var(--sui-textbox-background-color));
147
216
  }
148
- :is(textarea, .clone):is(:disabled, :read-only) {
217
+ :is(textarea, .clone):is(:disabled, :read-only):not(.clone) {
149
218
  background-color: var(--sui-disabled-background-color);
150
219
  }
151
220
 
@@ -164,6 +233,52 @@ textarea[aria-invalid=true] {
164
233
  overflow: hidden;
165
234
  visibility: hidden;
166
235
  }
236
+ .clone:not(.auto-resize) {
237
+ position: absolute;
238
+ inset: 0;
239
+ min-height: 0;
240
+ scroll-behavior: auto;
241
+ }
242
+
243
+ .highlighted textarea {
244
+ z-index: 1;
245
+ caret-color: var(--sui-textbox-foreground-color);
246
+ color: transparent !important;
247
+ background-color: transparent !important;
248
+ }
249
+ .highlighted textarea:not(.auto-resize) {
250
+ scrollbar-gutter: stable;
251
+ }
252
+ .highlighted textarea::placeholder {
253
+ color: var(--sui-textbox-placeholder-foreground-color, var(--sui-textbox-foreground-color));
254
+ opacity: var(--sui-textbox-placeholder-opacity, 1);
255
+ }
256
+ .highlighted .clone {
257
+ visibility: visible;
258
+ pointer-events: none;
259
+ }
260
+ .highlighted .clone .bold {
261
+ -webkit-text-stroke: 0.03em currentColor;
262
+ }
263
+ .highlighted .clone .underline {
264
+ text-decoration-line: underline;
265
+ }
266
+ .highlighted .clone .strikethrough {
267
+ text-decoration-line: line-through;
268
+ }
269
+ .highlighted .clone .underline.strikethrough {
270
+ text-decoration-line: underline line-through;
271
+ }
272
+ .highlighted .clone:not(.auto-resize) {
273
+ scrollbar-gutter: stable;
274
+ }
275
+ .highlighted:focus-within .clone {
276
+ color: var(--sui-textbox-foreground-color-focus, var(--sui-textbox-foreground-color));
277
+ background-color: var(--sui-textbox-background-color-focus, var(--sui-textbox-background-color));
278
+ }
279
+ .highlighted:is(.disabled, .readonly) .clone {
280
+ background-color: var(--sui-disabled-background-color);
281
+ }
167
282
 
168
283
  textarea,
169
284
  .clone {
@@ -40,6 +40,14 @@ declare const TextArea: import("svelte").Component<import("../../typedefs").Keyb
40
40
  * types a shortcode, like `:smi`.
41
41
  */
42
42
  useEmojiAutocomplete?: boolean | undefined;
43
+ /**
44
+ * Function to
45
+ * tokenize the value for syntax highlighting, returning one array of tokens per line, or
46
+ * `undefined` to show the text as is, for example while a grammar is still loading. The tokens
47
+ * are painted behind the text, which is made transparent, so the `<textarea>` keeps working as
48
+ * usual. Only styles that keep the text width can be applied, so the glyphs stay aligned.
49
+ */
50
+ highlight?: ((value: string) => HighlightedToken[][] | undefined) | undefined;
43
51
  /**
44
52
  * The `class` attribute on the wrapper element.
45
53
  */
@@ -104,6 +112,14 @@ type Props = {
104
112
  * types a shortcode, like `:smi`.
105
113
  */
106
114
  useEmojiAutocomplete?: boolean | undefined;
115
+ /**
116
+ * Function to
117
+ * tokenize the value for syntax highlighting, returning one array of tokens per line, or
118
+ * `undefined` to show the text as is, for example while a grammar is still loading. The tokens
119
+ * are painted behind the text, which is made transparent, so the `<textarea>` keeps working as
120
+ * usual. Only styles that keep the text width can be applied, so the glyphs stay aligned.
121
+ */
122
+ highlight?: ((value: string) => HighlightedToken[][] | undefined) | undefined;
107
123
  /**
108
124
  * The `class` attribute on the wrapper element.
109
125
  */
@@ -138,4 +154,5 @@ type Props = {
138
154
  children?: Snippet<[]> | undefined;
139
155
  };
140
156
  import type { InputEventHandlers } from '../../typedefs';
157
+ import type { HighlightedToken } from '../../typedefs';
141
158
  import type { Snippet } from 'svelte';
@@ -37,7 +37,7 @@
37
37
  font-style: normal;
38
38
  font-display: block;
39
39
  font-weight: 400;
40
- src: url(https://cdn.jsdelivr.net/fontsource/fonts/material-symbols-outlined:vf@5.3.7/latin-wght-normal.woff2)
40
+ src: url(https://cdn.jsdelivr.net/fontsource/fonts/material-symbols-outlined:vf@5.3.8/latin-wght-normal.woff2)
41
41
  format('woff2-variations');
42
42
  unicode-range:
43
43
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
@@ -69,7 +69,7 @@ export const FONTS = [
69
69
  {
70
70
  family: 'Material Symbols Outlined',
71
71
  packageName: '@fontsource-variable/material-symbols-outlined',
72
- version: '5.3.7',
72
+ version: '5.3.8',
73
73
  cdnId: 'material-symbols-outlined:vf',
74
74
  subset: 'latin-wght-normal',
75
75
  format: 'woff2-variations',
@@ -52,7 +52,7 @@ export class Group {
52
52
  rovingTabStop: "selected" | "first";
53
53
  observer: MutationObserver;
54
54
  /**
55
- * Activate the members.
55
+ * Activate the members, unless they already are.
56
56
  */
57
57
  activate(): void;
58
58
  /**
@@ -199,7 +199,7 @@ export class Group {
199
199
  activated = false;
200
200
 
201
201
  /**
202
- * Whether {@link destroy} has run, so a pending {@link activate} must not attach the listeners.
202
+ * Whether {@link destroy} has run, so a pending {@link activate} must not run.
203
203
  * @type {boolean}
204
204
  */
205
205
  #destroyed = false;
@@ -284,12 +284,14 @@ export class Group {
284
284
  // eslint-disable-next-line jsdoc/require-description
285
285
  /** @type {(event: MouseEvent) => void} */
286
286
  this._onClick = (event) => {
287
+ this.activate();
287
288
  this.onClick(event);
288
289
  };
289
290
 
290
291
  // eslint-disable-next-line jsdoc/require-description
291
292
  /** @type {(event: KeyboardEvent) => void} */
292
293
  this._onKeyDown = (event) => {
294
+ this.activate();
293
295
  this.onKeyDown(event);
294
296
  };
295
297
 
@@ -352,12 +354,16 @@ export class Group {
352
354
  attributeFilter: ['aria-disabled', 'aria-hidden'],
353
355
  });
354
356
 
357
+ // Listen right away: a click or a key press that comes before the activation below activates
358
+ // the members first, rather than being lost
359
+ parent.addEventListener('click', this._onClick);
360
+ parent.addEventListener('keydown', this._onKeyDown);
361
+
355
362
  // Wait a bit before the relevant components, including the `aria-controls` target are mounted
356
363
  (async () => {
357
364
  await sleep(100);
358
365
 
359
- // The widget may have been unmounted in the meantime, and the listeners added by `activate()`
360
- // would then never be removed
366
+ // The widget may have been unmounted in the meantime
361
367
  if (!this.#destroyed) {
362
368
  this.activate();
363
369
  }
@@ -365,9 +371,13 @@ export class Group {
365
371
  }
366
372
 
367
373
  /**
368
- * Activate the members.
374
+ * Activate the members, unless they already are.
369
375
  */
370
376
  activate() {
377
+ if (this.activated) {
378
+ return;
379
+ }
380
+
371
381
  const { parent, allMembers, selected: defaultSelected } = this;
372
382
 
373
383
  this.activated = true;
@@ -408,8 +418,6 @@ export class Group {
408
418
  });
409
419
 
410
420
  this.updateTabStop();
411
- parent.addEventListener('click', this._onClick);
412
- parent.addEventListener('keydown', this._onKeyDown);
413
421
  parent.dispatchEvent(new CustomEvent('Initialized'));
414
422
  }
415
423
 
@@ -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 activate} has run.
50
+ * @type {boolean}
51
+ */
52
+ activated: boolean;
48
53
  /**
49
54
  * Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
50
55
  * @type {boolean}
@@ -58,7 +63,7 @@ export class Tree {
58
63
  _onFocusIn: (event: FocusEvent) => void;
59
64
  observer: MutationObserver;
60
65
  /**
61
- * Activate the items.
66
+ * Activate the items, unless they already are.
62
67
  */
63
68
  activate(): void;
64
69
  /**
@@ -67,6 +67,12 @@ export class Tree {
67
67
  */
68
68
  this.typeAheadTimer = undefined;
69
69
 
70
+ /**
71
+ * Whether {@link activate} has run.
72
+ * @type {boolean}
73
+ */
74
+ this.activated = false;
75
+
70
76
  /**
71
77
  * Whether {@link destroy} has run, so a pending {@link activate} must not set anything up.
72
78
  * @type {boolean}
@@ -76,18 +82,21 @@ export class Tree {
76
82
  // eslint-disable-next-line jsdoc/require-description
77
83
  /** @type {(event: MouseEvent) => void} */
78
84
  this._onClick = (event) => {
85
+ this.activate();
79
86
  this.onClick(event);
80
87
  };
81
88
 
82
89
  // eslint-disable-next-line jsdoc/require-description
83
90
  /** @type {(event: KeyboardEvent) => void} */
84
91
  this._onKeyDown = (event) => {
92
+ this.activate();
85
93
  this.onKeyDown(event);
86
94
  };
87
95
 
88
96
  // eslint-disable-next-line jsdoc/require-description
89
97
  /** @type {(event: FocusEvent) => void} */
90
98
  this._onFocusIn = (event) => {
99
+ this.activate();
91
100
  this.onFocusIn(event);
92
101
  };
93
102
 
@@ -99,12 +108,18 @@ export class Tree {
99
108
  // The widget root itself is never part of the tab order; one of the items always is
100
109
  this.parent.tabIndex = -1;
101
110
 
111
+ // Listen right away: a click, a key press or a focus that comes before the activation below
112
+ // activates the items first, rather than being lost
113
+ parent.addEventListener('click', this._onClick);
114
+ parent.addEventListener('keydown', this._onKeyDown);
115
+ parent.addEventListener('focusin', this._onFocusIn);
116
+
102
117
  // Wait a bit before the child components are mounted
103
118
  (async () => {
104
119
  await sleep(100);
105
120
 
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
121
+ // The widget may have been unmounted in the meantime, and the observer set up by
122
+ // `activate()` would then never be disconnected
108
123
  if (!this.destroyed) {
109
124
  this.activate();
110
125
  }
@@ -112,16 +127,17 @@ export class Tree {
112
127
  }
113
128
 
114
129
  /**
115
- * Activate the items.
130
+ * Activate the items, unless they already are.
116
131
  */
117
132
  activate() {
133
+ if (this.activated) {
134
+ return;
135
+ }
136
+
118
137
  const { parent } = this;
119
138
 
139
+ this.activated = true;
120
140
  this.update();
121
-
122
- parent.addEventListener('click', this._onClick);
123
- parent.addEventListener('keydown', this._onKeyDown);
124
- parent.addEventListener('focusin', this._onFocusIn);
125
141
  this.observer.observe(parent, { childList: true, subtree: true });
126
142
  parent.dispatchEvent(new CustomEvent('Initialized'));
127
143
  }