@sveltia/ui 0.55.0 → 0.56.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.
@@ -5,6 +5,8 @@
5
5
  @see https://w3c.github.io/aria/#option
6
6
  -->
7
7
  <script>
8
+ import { onDestroy } from 'svelte';
9
+ import { getOptionRegistry } from '../../services/select.svelte.js';
8
10
  import Button from '../button/button.svelte';
9
11
  import Icon from '../icon/icon.svelte';
10
12
 
@@ -47,42 +49,114 @@
47
49
  ...restProps
48
50
  /* eslint-enable prefer-const */
49
51
  } = $props();
52
+
53
+ /**
54
+ * The registry provided by an ancestor `<Combobox>`. This is `undefined` when the option is used
55
+ * standalone within a `<Listbox>`, in which case it always renders itself.
56
+ */
57
+ const registry = getOptionRegistry();
58
+
59
+ if (registry) {
60
+ // Expose the props as accessors, so the combobox always reads the current values
61
+ const unregister = registry.register({
62
+ /**
63
+ * Get the option’s value.
64
+ * @returns {any} Value.
65
+ */
66
+ get value() {
67
+ return value;
68
+ },
69
+ /**
70
+ * Get the option’s text label.
71
+ * @returns {string} Label.
72
+ */
73
+ get label() {
74
+ return label;
75
+ },
76
+ /**
77
+ * Get the option’s name.
78
+ * @returns {string | undefined} Name.
79
+ */
80
+ get name() {
81
+ return restProps.name;
82
+ },
83
+ /**
84
+ * Get the data type of the option’s value.
85
+ * @returns {string} Type.
86
+ */
87
+ get type() {
88
+ return restProps.valueType ?? typeof value;
89
+ },
90
+ /**
91
+ * Get whether the option is selected.
92
+ * @returns {boolean} Result.
93
+ */
94
+ get selected() {
95
+ return selected;
96
+ },
97
+ /**
98
+ * Select or deselect the option.
99
+ * @param {boolean} newValue `true` to select.
100
+ */
101
+ set selected(newValue) {
102
+ selected = newValue;
103
+ },
104
+ /**
105
+ * Get whether the option is disabled.
106
+ * @returns {boolean} Result.
107
+ */
108
+ get disabled() {
109
+ return disabled;
110
+ },
111
+ });
112
+
113
+ onDestroy(unregister);
114
+ }
115
+
116
+ /**
117
+ * Whether to render the option. Within a `<Combobox>`, the options are only rendered while the
118
+ * dropdown is expanded; the registration above is what keeps the collapsed combobox working.
119
+ * @type {boolean}
120
+ */
121
+ const rendered = $derived(!registry || registry.expanded);
50
122
  </script>
51
123
 
52
- <div role="none" class="sui option {className}" class:wrap {hidden}>
53
- <Button
54
- {...restProps}
55
- role="option"
56
- tabindex="-1"
57
- aria-selected={selected}
58
- {label}
59
- {value}
60
- {hidden}
61
- {disabled}
62
- data-search-value={searchValue}
63
- onChange={(event) => {
64
- selected = event.detail.selected;
65
- onChange?.(event);
66
- }}
67
- onToggle={(event) => {
68
- hidden = event.detail.hidden;
69
- if (hidden) selected = false;
70
- onToggle?.(event);
71
- }}
72
- >
73
- {#if selected}
74
- {#if checkIcon}
75
- {@render checkIcon()}
76
- {:else}
77
- <Icon class="check" name="check" />
124
+ {#if rendered}
125
+ <div role="none" class="sui option {className}" class:wrap {hidden}>
126
+ <Button
127
+ {...restProps}
128
+ role="option"
129
+ tabindex="-1"
130
+ aria-selected={selected}
131
+ {label}
132
+ {value}
133
+ {hidden}
134
+ {disabled}
135
+ data-search-value={searchValue}
136
+ onChange={(event) => {
137
+ selected = event.detail.selected;
138
+ onChange?.(event);
139
+ }}
140
+ onToggle={(event) => {
141
+ hidden = event.detail.hidden;
142
+ if (hidden) selected = false;
143
+ onToggle?.(event);
144
+ }}
145
+ >
146
+ {#if selected}
147
+ {#if checkIcon}
148
+ {@render checkIcon()}
149
+ {:else}
150
+ <Icon class="check" name="check" />
151
+ {/if}
78
152
  {/if}
79
- {/if}
80
- {#snippet startIcon()}
81
- {@render _startIcon?.()}
82
- {/snippet}
83
- {@render children?.()}
84
- </Button>
85
- </div>
153
+ {#snippet startIcon()}
154
+ {@render _startIcon?.()}
155
+ {/snippet}
156
+ {@render children?.()}
157
+ </Button>
158
+ </div>
159
+ {/if}
86
160
 
87
161
  <style>.option {
88
162
  display: contents;
@@ -7,7 +7,8 @@
7
7
  -->
8
8
  <script>
9
9
  import { _ } from '@sveltia/i18n';
10
- import { getSelectedItemDetail } from '../../services/select.svelte.js';
10
+ import { onMount } from 'svelte';
11
+ import { createOptionRegistry, getSelectedItemDetail } from '../../services/select.svelte.js';
11
12
  import Button from '../button/button.svelte';
12
13
  import Icon from '../icon/icon.svelte';
13
14
  import Listbox from '../listbox/listbox.svelte';
@@ -20,12 +21,6 @@
20
21
  * @import { ComboboxProps, TextInputProps } from '../../typedefs';
21
22
  */
22
23
 
23
- /**
24
- * Selector for the currently selected option in the popup. Used to update the selected option
25
- * when the value is changed externally.
26
- */
27
- const SELECTED_SELECTOR = '[role="option"][aria-selected="true"]';
28
-
29
24
  /**
30
25
  * @type {ComboboxProps & TextInputProps & Record<string, any>}
31
26
  */
@@ -58,32 +53,61 @@
58
53
  let inputElement = $state();
59
54
  /** @type {HTMLElement | undefined} */
60
55
  let popupContent = $state();
56
+ /**
57
+ * Wrapper holding the options while the dropdown is collapsed.
58
+ * @type {HTMLElement | undefined}
59
+ */
60
+ let idleHost = $state();
61
+ /**
62
+ * Wrapper holding the options, moved between {@link idleHost} and {@link listboxSlot}.
63
+ * @type {HTMLElement | undefined}
64
+ */
65
+ let optionHost = $state();
66
+ /**
67
+ * Where the options go while the dropdown is expanded.
68
+ * @type {HTMLElement | undefined}
69
+ */
70
+ let listboxSlot = $state();
61
71
  /** @type {string} */
62
72
  let label = $state('');
63
73
  /** @type {boolean} */
64
74
  let showFilter = $state(false);
65
75
  /** @type {string} */
66
76
  let searchTerms = $state('');
67
- /** @type {boolean} */
68
- let hasMatchingOptions = $state(true);
69
- /** @type {HTMLElement} */
77
+ /**
78
+ * Number of options matching the current search terms, as last reported by the listbox.
79
+ * @type {number}
80
+ */
81
+ let matchedOptions = $state(0);
82
+
83
+ /**
84
+ * Whether to keep the option list free of the “no matching options” notice. That notice is about
85
+ * filtering, so it’s only meaningful once search terms have been entered — the listbox also
86
+ * reports zero matches while the options are unrendered, which says nothing about the filter.
87
+ * @type {boolean}
88
+ */
89
+ const hasMatchingOptions = $derived(!searchTerms || matchedOptions > 0);
90
+ /**
91
+ * @type {HTMLElement}
92
+ */
70
93
  const anchor = $derived(/** @type {HTMLElement} */ (comboboxElement ?? inputElement));
71
94
 
95
+ /**
96
+ * The options declared as `<Option>` children. They only render while the dropdown is expanded,
97
+ * so the label and the initial value below are resolved through the registry rather than the DOM
98
+ * tree.
99
+ */
100
+ const registry = createOptionRegistry();
101
+
72
102
  /**
73
103
  * Update the {@link label} and selected option when the {@link value} is changed.
74
104
  */
75
105
  const _onChange = () => {
76
- const target = /** @type {HTMLButtonElement | null} */ (
77
- popupContent?.querySelector(`[role="option"][data-value="${value}"]`)
78
- );
106
+ const entry = registry.find(value);
79
107
 
80
- if (target) {
81
- label = target.dataset.label || target.dataset.value || target.textContent || '';
82
-
83
- if (target.getAttribute('aria-selected') !== 'true') {
84
- popupContent?.querySelector(SELECTED_SELECTOR)?.setAttribute('aria-selected', 'false');
85
- target.setAttribute('aria-selected', 'true');
86
- }
108
+ if (entry) {
109
+ label = entry.label;
110
+ registry.selectOnly(value);
87
111
  }
88
112
  };
89
113
 
@@ -100,24 +124,65 @@
100
124
  onChange?.(new CustomEvent('Change', { detail }));
101
125
  };
102
126
 
127
+ // Let the options know whether they should render themselves
103
128
  $effect(() => {
104
- if (popupContent) {
105
- globalThis.requestAnimationFrame(() => {
106
- const selected = popupContent?.querySelector(SELECTED_SELECTOR);
129
+ registry.expanded = isPopupOpen;
130
+ });
107
131
 
108
- if (selected) {
109
- _onSelect(/** @type {HTMLButtonElement} */ (selected));
110
- }
111
- });
132
+ // Move the options into the popup while it’s expanded, and back out before it’s unmounted. Only
133
+ // the wrapper is moved, never its children, so Svelte keeps full ownership of the subtree.
134
+ $effect(() => {
135
+ if (!optionHost) {
136
+ return;
137
+ }
138
+
139
+ const parent = isPopupOpen ? listboxSlot : idleHost;
140
+
141
+ if (parent && optionHost.parentElement !== parent) {
142
+ parent.append(optionHost);
112
143
  }
113
144
  });
114
145
 
146
+ // Derive the initial value from the `<Option>` that is marked as selected in the markup. This has
147
+ // to be a one-off read rather than an effect on `registry.selectedEntry`: where the markup marks
148
+ // nothing as selected, the first entry to become selected is the one the user picks, and
149
+ // reporting that here would raise a second `Change` for a single choice. The options register
150
+ // during their own initialization, so they are all present by the time this runs.
151
+ onMount(() => {
152
+ const entry = registry.selectedEntry;
153
+
154
+ if (!entry) {
155
+ return;
156
+ }
157
+
158
+ value = entry.value;
159
+ label = entry.label;
160
+
161
+ // There is no element to report while the dropdown has never been expanded
162
+ onChange?.(
163
+ new CustomEvent('Change', {
164
+ detail: {
165
+ target: undefined,
166
+ type: entry.type,
167
+ name: entry.name,
168
+ label: entry.label,
169
+ value: entry.value,
170
+ },
171
+ }),
172
+ );
173
+ });
174
+
115
175
  $effect(() => {
116
176
  void value;
117
177
  _onChange();
118
178
  });
119
179
  </script>
120
180
 
181
+ <!--
182
+ `aria-controls` is deliberately absent from the `role="combobox"` element below: the popup is
183
+ only in the DOM tree while it’s expanded, and a reference to a missing element is worse than no
184
+ reference at all. The popup service adds it on open and removes it on close.
185
+ -->
121
186
  <div {...restProps} role="none" class="sui combobox {className}" class:editable {hidden}>
122
187
  {#if !editable}
123
188
  <div
@@ -127,7 +192,6 @@
127
192
  {id}
128
193
  class:selected={value !== undefined}
129
194
  tabindex={disabled ? -1 : 0}
130
- aria-controls="{id}-popup"
131
195
  aria-expanded={isPopupOpen}
132
196
  aria-hidden={hidden}
133
197
  aria-disabled={disabled}
@@ -156,7 +220,6 @@
156
220
  {readonly}
157
221
  {required}
158
222
  {invalid}
159
- aria-controls="{id}-popup"
160
223
  aria-expanded={isPopupOpen}
161
224
  aria-haspopup="listbox"
162
225
  aria-label={ariaLabel}
@@ -169,7 +232,7 @@
169
232
  {disabled}
170
233
  tabindex={!editable || readonly || disabled ? -1 : 0}
171
234
  aria-label={isPopupOpen ? _('_sui.collapse') : _('_sui.expand')}
172
- aria-controls="{id}-popup"
235
+ aria-controls={isPopupOpen ? `${id}-popup` : undefined}
173
236
  aria-expanded={isPopupOpen}
174
237
  onclick={(event) => {
175
238
  event.preventDefault();
@@ -189,6 +252,32 @@
189
252
  {/snippet}
190
253
  </Button>
191
254
  </div>
255
+ <!--
256
+ The `<Option>`s have to stay instantiated while the dropdown is collapsed, so that the registry
257
+ above can resolve the current label. They render no DOM of their own in that state, so this host
258
+ is empty apart from any other markup the consumer interleaved, such as a `<Divider>`. It’s moved
259
+ into the popup when the dropdown expands, which keeps a single set of component instances and lets
260
+ Svelte own the rendering order.
261
+ -->
262
+ <div bind:this={idleHost} role="none" class="idle-host" hidden>
263
+ <div bind:this={optionHost} role="none" class="option-host">
264
+ <Listbox
265
+ id="{id}-listbox"
266
+ class="in-combobox"
267
+ {searchTerms}
268
+ onclick={(event) => {
269
+ if (/** @type {HTMLElement} */ (event.target).matches('[role="option"]')) {
270
+ _onSelect(/** @type {HTMLButtonElement} */ (event.target));
271
+ }
272
+ }}
273
+ onFilter={(event) => {
274
+ matchedOptions = /** @type {CustomEvent} */ (event).detail.matched;
275
+ }}
276
+ >
277
+ {@render children?.()}
278
+ </Listbox>
279
+ </div>
280
+ </div>
192
281
  <Popup
193
282
  bind:content={popupContent}
194
283
  id="{id}-popup"
@@ -198,10 +287,7 @@
198
287
  touchOptimized={true}
199
288
  bind:open={isPopupOpen}
200
289
  onOpen={() => {
201
- showFilter =
202
- filterThreshold === -1
203
- ? false
204
- : (popupContent?.querySelectorAll('[role="option"]')?.length ?? 0) > filterThreshold;
290
+ showFilter = filterThreshold === -1 ? false : registry.count > filterThreshold;
205
291
  searchTerms = '';
206
292
  }}
207
293
  >
@@ -222,21 +308,7 @@
222
308
  }}
223
309
  />
224
310
  {/if}
225
- <Listbox
226
- id="{id}-listbox"
227
- class="in-combobox"
228
- {searchTerms}
229
- onclick={(event) => {
230
- if (/** @type {HTMLElement} */ (event.target).matches('[role="option"]')) {
231
- _onSelect(/** @type {HTMLButtonElement} */ (event.target));
232
- }
233
- }}
234
- onFilter={(event) => {
235
- hasMatchingOptions = !!(/** @type {CustomEvent} */ (event).detail.matched);
236
- }}
237
- >
238
- {@render children?.()}
239
- </Listbox>
311
+ <div bind:this={listboxSlot} role="none" class="listbox-slot"></div>
240
312
  {#if !hasMatchingOptions}
241
313
  <div role="alert" class="no-options" aria-live="assertive">
242
314
  {_('_sui.combobox.no_matching_options')}
@@ -245,7 +317,11 @@
245
317
  </div>
246
318
  </Popup>
247
319
 
248
- <style>.combobox {
320
+ <style>:is(.option-host, .listbox-slot) {
321
+ display: contents;
322
+ }
323
+
324
+ .combobox {
249
325
  margin: var(--sui-focus-ring-width);
250
326
  display: flex;
251
327
  align-items: center;
@@ -36,10 +36,6 @@ export const INLINE_BUTTON_TYPES: TextEditorInlineType[];
36
36
  * @type {TextEditorBlockType[]}
37
37
  */
38
38
  export const BLOCK_BUTTON_TYPES: TextEditorBlockType[];
39
- /**
40
- * Image related components IDs. `linked-image` is used in Sveltia CMS.
41
- */
42
- export const IMAGE_COMPONENT_IDS: string[];
43
39
  /**
44
40
  * Map of Lexical nodes for each block type. The `paragraph` block type is excluded because it is
45
41
  * the default block type.
@@ -199,11 +199,6 @@ export const BLOCK_BUTTON_TYPES = [
199
199
  'code-block',
200
200
  ];
201
201
 
202
- /**
203
- * Image related components IDs. `linked-image` is used in Sveltia CMS.
204
- */
205
- export const IMAGE_COMPONENT_IDS = ['image', 'linked-image'];
206
-
207
202
  /**
208
203
  * Map of Lexical nodes for each block type. The `paragraph` block type is excluded because it is
209
204
  * the default block type.
@@ -13,7 +13,7 @@
13
13
 
14
14
  /**
15
15
  * @typedef {object} Props
16
- * @property {TextEditorComponent} component Image editor component.
16
+ * @property {TextEditorComponent} component Editor component.
17
17
  */
18
18
 
19
19
  /** @type {Props} */
@@ -25,20 +25,27 @@
25
25
 
26
26
  /** @type {TextEditorStore} */
27
27
  const editorStore = getContext('editorStore');
28
+
29
+ const { label, icon, createNode } = $derived(component);
28
30
  </script>
29
31
 
30
32
  <Button
31
- iconic
32
- aria-label={component.label}
33
+ iconic={!!icon}
34
+ label={icon ? undefined : label}
35
+ title={label}
36
+ aria-label={label}
33
37
  aria-controls="{editorStore.editorId}-lexical-root"
34
38
  disabled={!editorStore.useRichText}
35
39
  onclick={() => {
36
40
  editorStore.editor?.update(() => {
37
- insertNodes([component.createNode(), createParagraphNode()]);
41
+ // Add an additional paragraph for easier editing
42
+ insertNodes([createNode(), createParagraphNode()]);
38
43
  });
39
44
  }}
40
45
  >
41
46
  {#snippet startIcon()}
42
- <Icon name={component.icon} />
47
+ {#if icon}
48
+ <Icon name={icon} />
49
+ {/if}
43
50
  {/snippet}
44
51
  </Button>
@@ -1,17 +1,17 @@
1
- export default InsertImageButton;
2
- type InsertImageButton = {
1
+ export default InsertItemButton;
2
+ type InsertItemButton = {
3
3
  $on?(type: string, callback: (e: any) => void): () => void;
4
4
  $set?(props: Partial<Props>): void;
5
5
  };
6
- declare const InsertImageButton: import("svelte").Component<{
6
+ declare const InsertItemButton: import("svelte").Component<{
7
7
  /**
8
- * Image editor component.
8
+ * Editor component.
9
9
  */
10
10
  component: TextEditorComponent;
11
11
  }, {}, "">;
12
12
  type Props = {
13
13
  /**
14
- * Image editor component.
14
+ * Editor component.
15
15
  */
16
16
  component: TextEditorComponent;
17
17
  };
@@ -1,6 +1,9 @@
1
1
  <script>
2
2
  import { _ } from '@sveltia/i18n';
3
- import { $insertNodes as insertNodes } from 'lexical';
3
+ import {
4
+ $createParagraphNode as createParagraphNode,
5
+ $insertNodes as insertNodes,
6
+ } from 'lexical';
4
7
  import { getContext } from 'svelte';
5
8
  import Icon from '../../icon/icon.svelte';
6
9
  import MenuButton from '../../menu/menu-button.svelte';
@@ -38,7 +41,8 @@
38
41
  {label}
39
42
  onclick={() => {
40
43
  editorStore.editor?.update(() => {
41
- insertNodes([createNode()]);
44
+ // Add an additional paragraph for easier editing
45
+ insertNodes([createNode(), createParagraphNode()]);
42
46
  });
43
47
  }}
44
48
  >
@@ -9,15 +9,10 @@
9
9
  import Icon from '../../icon/icon.svelte';
10
10
  import MenuButton from '../../menu/menu-button.svelte';
11
11
  import Menu from '../../menu/menu.svelte';
12
- import {
13
- AVAILABLE_BUTTONS,
14
- BLOCK_BUTTON_TYPES,
15
- IMAGE_COMPONENT_IDS,
16
- INLINE_BUTTON_TYPES,
17
- } from '../constants.js';
12
+ import { AVAILABLE_BUTTONS, BLOCK_BUTTON_TYPES, INLINE_BUTTON_TYPES } from '../constants.js';
18
13
  import CodeLanguageSwitcher from './code-language-switcher.svelte';
19
14
  import FormatTextButton from './format-text-button.svelte';
20
- import InsertImageButton from './insert-image-button.svelte';
15
+ import InsertItemButton from './insert-item-button.svelte';
21
16
  import InsertLinkButton from './insert-link-button.svelte';
22
17
  import InsertMenuButton from './insert-menu-button.svelte';
23
18
  import ToggleBlockMenuItem from './toggle-block-menu-item.svelte';
@@ -48,11 +43,12 @@
48
43
 
49
44
  /** @type {TextEditorStore} */
50
45
  const editorStore = getContext('editorStore');
51
- const imageComponent = $derived(
52
- editorStore.config.components.find(({ id }) => IMAGE_COMPONENT_IDS.includes(id)),
46
+
47
+ const buttons = $derived(
48
+ editorStore.config.components.filter(({ trigger = 'menuitem' }) => trigger === 'button'),
53
49
  );
54
- const otherComponents = $derived(
55
- editorStore.config.components.filter(({ id }) => !IMAGE_COMPONENT_IDS.includes(id)),
50
+ const menuitems = $derived(
51
+ editorStore.config.components.filter(({ trigger = 'menuitem' }) => trigger === 'menuitem'),
56
52
  );
57
53
 
58
54
  /**
@@ -121,11 +117,11 @@
121
117
  {/if}
122
118
  {#if editorStore.config.components.length}
123
119
  <Divider orientation="vertical" />
124
- {#if imageComponent}
125
- <InsertImageButton component={imageComponent} />
126
- {/if}
127
- {#if otherComponents.length}
128
- <InsertMenuButton components={otherComponents} />
120
+ {#each buttons as button (button.id)}
121
+ <InsertItemButton component={button} />
122
+ {/each}
123
+ {#if menuitems.length}
124
+ <InsertMenuButton components={menuitems} />
129
125
  {/if}
130
126
  {/if}
131
127
  {/if}