@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.
@@ -31,8 +31,18 @@ class Popup {
31
31
  } else if (this.anchorElement.getAttribute('aria-expanded') === 'true') {
32
32
  // Wait for the popup to close before focusing the anchor, otherwise the focus will be lost
33
33
  window.requestAnimationFrame(() => {
34
- this.anchorElement.focus();
35
- this.anchorElement.removeAttribute('aria-controls');
34
+ const { activeElement } = document;
35
+
36
+ // Only take focus back if it’s still inside the popup, or nowhere because the popup took it
37
+ // down with itself. If it has already moved on — the user tabbed out of the menu, say —
38
+ // pulling it back would undo what they just did.
39
+ if (
40
+ !activeElement ||
41
+ activeElement === document.body ||
42
+ this.popupElement?.contains(activeElement)
43
+ ) {
44
+ this.anchorElement.focus();
45
+ }
36
46
  });
37
47
  }
38
48
 
@@ -59,7 +69,18 @@ class Popup {
59
69
  return;
60
70
  }
61
71
 
62
- const content = /** @type {HTMLElement} */ (this.popupElement.querySelector('.content'));
72
+ // Use the tracked content element rather than searching the popup element, which for a nested
73
+ // popup is the shared parent `<dialog>` and would yield the parent’s content
74
+ const content = /** @type {HTMLElement | null} */ (
75
+ this.contentElement ?? this.popupElement?.querySelector('.content') ?? null
76
+ );
77
+
78
+ // The content is not in the DOM tree yet; `checkPosition()` will be called again once the
79
+ // popup element is attached
80
+ if (!content) {
81
+ return;
82
+ }
83
+
63
84
  const { scrollHeight: contentHeight, scrollWidth: contentWidth } = content;
64
85
  const topMargin = intersectionRect.top - 8;
65
86
  const bottomMargin = rootBounds.height - intersectionRect.bottom - 8;
@@ -155,22 +176,44 @@ class Popup {
155
176
  });
156
177
 
157
178
  /**
158
- * Initialize a new `Popup` instance.
179
+ * A reference to the `<dialog>` element used for the popup, which also serves as the backdrop.
180
+ * This is `undefined` while the element is not in the DOM tree, which is the case for a closed
181
+ * popup that doesn’t keep its content.
182
+ * @type {HTMLDialogElement | undefined}
183
+ */
184
+ popupElement = undefined;
185
+
186
+ /**
187
+ * A reference to the element holding the popup content. Unlike {@link popupElement}, which a
188
+ * nested popup shares with its parent, this element belongs to this popup alone, so it’s the one
189
+ * that carries the {@link id} and that the anchor’s `aria-controls` points at.
190
+ * @type {HTMLElement | undefined}
191
+ */
192
+ contentElement = undefined;
193
+
194
+ /**
195
+ * Function that removes the event listeners added to the current {@link popupElement}.
196
+ * @type {(() => void) | undefined}
197
+ */
198
+ #removeEventListeners = undefined;
199
+
200
+ /**
201
+ * Initialize a new `Popup` instance. Note that the `popupElement` is optional, because the
202
+ * element is typically mounted only while the popup is open. Use {@link attachPopupElement} to
203
+ * provide it later.
159
204
  * @param {HTMLButtonElement} anchorElement `<button>` element that triggers the popup.
160
- * @param {HTMLDialogElement} popupElement `<dialog>` element to be used for the popup.
205
+ * @param {HTMLDialogElement | undefined} popupElement `<dialog>` element to be used for the
206
+ * popup, if it’s already in the DOM tree.
161
207
  * @param {PopupPosition} position Where to show the popup content.
162
208
  * @param {HTMLElement} [positionBaseElement] The base element of the `position`. If omitted, this
163
209
  * will be the `anchorElement`.
164
210
  */
165
211
  constructor(anchorElement, popupElement, position, positionBaseElement) {
166
212
  this.anchorElement = anchorElement;
167
- this.popupElement = popupElement; // = backdrop
168
213
  this.position = position;
169
214
  this.positionBaseElement = positionBaseElement ?? anchorElement;
170
215
  this.id = generateElementId('popup');
171
216
 
172
- this.anchorElement.setAttribute('aria-controls', this.id);
173
- this.popupElement.setAttribute('id', this.id);
174
217
  this.anchorElement.setAttribute('aria-expanded', 'false');
175
218
 
176
219
  on(anchorElement, 'click', () => {
@@ -203,8 +246,45 @@ class Popup {
203
246
  });
204
247
  this.intersectionObserver.observe(this.anchorElement);
205
248
 
249
+ // Update the popup width when the base element is resized
250
+ this.resizeObserver = new ResizeObserver(() => {
251
+ cancelAnimationFrame(this._rafId);
252
+ this._rafId = requestAnimationFrame(() => this.checkPosition());
253
+ });
254
+ this.resizeObserver.observe(this.positionBaseElement);
255
+
256
+ if (popupElement) {
257
+ this.attachPopupElement(popupElement);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Attach the `<dialog>` element used for the popup. This is called every time the element is
263
+ * mounted, which happens on each open.
264
+ * @param {HTMLDialogElement} popupElement `<dialog>` element to be used for the popup. A nested
265
+ * popup shares this with its parent, so it must not be labelled as belonging to this popup.
266
+ * @param {HTMLElement} [contentElement] Element holding this popup’s content. When omitted, the
267
+ * `popupElement` is assumed to hold the content on its own.
268
+ */
269
+ attachPopupElement(popupElement, contentElement) {
270
+ if (this.popupElement === popupElement && this.contentElement === contentElement) {
271
+ return;
272
+ }
273
+
274
+ this.detachPopupElement();
275
+ this.popupElement = popupElement;
276
+ this.contentElement = contentElement;
277
+
278
+ // Identify the popup by the element that actually holds its content. Labelling the `<dialog>`
279
+ // would be wrong for a nested popup, which shares its parent’s: it would overwrite the parent’s
280
+ // own `id` and leave the anchor pointing at the parent instead of the submenu.
281
+ const identifiedElement = contentElement ?? popupElement;
282
+
283
+ identifiedElement.id = this.id;
284
+ this.anchorElement.setAttribute('aria-controls', this.id);
285
+
206
286
  // Close the popup when the backdrop, a menu item or an option is clicked
207
- on(this.popupElement, 'click', (event) => {
287
+ const removeClickListener = on(popupElement, 'click', (event) => {
208
288
  event.stopPropagation();
209
289
 
210
290
  // eslint-disable-next-line prefer-destructuring
@@ -218,7 +298,7 @@ class Popup {
218
298
  }
219
299
  });
220
300
 
221
- on(this.popupElement, 'keydown', (event) => {
301
+ const removeKeyDownListener = on(popupElement, 'keydown', (event) => {
222
302
  const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
223
303
  const hasModifier = shiftKey || altKey || ctrlKey || metaKey;
224
304
 
@@ -229,12 +309,32 @@ class Popup {
229
309
  }
230
310
  });
231
311
 
232
- // Update the popup width when the base element is resized
233
- this.resizeObserver = new ResizeObserver(() => {
234
- cancelAnimationFrame(this._rafId);
235
- this._rafId = requestAnimationFrame(() => this.checkPosition());
236
- });
237
- this.resizeObserver.observe(this.positionBaseElement);
312
+ /**
313
+ * Remove the listeners added above.
314
+ */
315
+ this.#removeEventListeners = () => {
316
+ removeClickListener();
317
+ removeKeyDownListener();
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Detach the `<dialog>` element, typically because it’s being unmounted. The `aria-controls`
323
+ * attribute on the anchor is left to the {@link open} setter, which removes it once the closing
324
+ * animation is complete.
325
+ */
326
+ detachPopupElement() {
327
+ this.#removeEventListeners?.();
328
+ this.#removeEventListeners = undefined;
329
+
330
+ // The content is leaving the DOM tree, so the anchor must stop referencing it. Only clear a
331
+ // reference this popup owns; the consumer may have pointed the anchor somewhere else.
332
+ if (this.anchorElement.getAttribute('aria-controls') === this.id) {
333
+ this.anchorElement.removeAttribute('aria-controls');
334
+ }
335
+
336
+ this.popupElement = undefined;
337
+ this.contentElement = undefined;
238
338
  }
239
339
 
240
340
  /**
@@ -254,9 +354,14 @@ class Popup {
254
354
  }
255
355
 
256
356
  /**
257
- * Check the position of the anchor element.
357
+ * Check the position of the anchor element. This is a no-op while the popup element is not in the
358
+ * DOM tree; the caller is expected to call this again once the element is attached.
258
359
  */
259
360
  checkPosition() {
361
+ if (!this.popupElement) {
362
+ return;
363
+ }
364
+
260
365
  this.observer.unobserve(this.positionBaseElement);
261
366
  this.observer.observe(this.positionBaseElement);
262
367
  }
@@ -265,16 +370,24 @@ class Popup {
265
370
  * Hide the popup immediately (when the anchor is being hidden).
266
371
  */
267
372
  async hideImmediately() {
268
- this.popupElement.hidden = true;
373
+ if (this.popupElement) {
374
+ this.popupElement.hidden = true;
375
+ }
376
+
269
377
  this.open = false;
270
378
  await sleep(50);
271
- this.popupElement.hidden = false;
379
+
380
+ // The element may have been unmounted in the meantime
381
+ if (this.popupElement) {
382
+ this.popupElement.hidden = false;
383
+ }
272
384
  }
273
385
 
274
386
  /**
275
387
  * Dispose of the popup, disconnecting observers and canceling pending work.
276
388
  */
277
389
  destroy() {
390
+ this.detachPopupElement();
278
391
  this.intersectionObserver?.disconnect();
279
392
  this.resizeObserver?.disconnect();
280
393
  this.observer?.disconnect();
@@ -1,2 +1,51 @@
1
+ /**
2
+ * Reactive registry of the `<Option>`s declared within a `<Combobox>`.
3
+ *
4
+ * The options only render their DOM while the dropdown is expanded, so a collapsed combobox can’t
5
+ * look them up with `querySelector`. Each `<Option>` instead registers a live view of its own props
6
+ * here, which lets the combobox resolve the current label and the initially selected value without
7
+ * keeping the option elements in the DOM tree.
8
+ *
9
+ * The order of the registered options is not significant. Svelte still renders the options itself,
10
+ * in declaration order, so nothing here has to reproduce that order.
11
+ */
12
+ export class OptionRegistry {
13
+ /**
14
+ * Whether the dropdown is expanded, meaning the options should render their DOM.
15
+ * @type {boolean}
16
+ */
17
+ expanded: boolean;
18
+ /**
19
+ * Number of registered options.
20
+ * @returns {number} Count.
21
+ */
22
+ get count(): number;
23
+ /**
24
+ * The option that is currently marked as selected, if any.
25
+ * @returns {OptionEntry | undefined} Matching option.
26
+ */
27
+ get selectedEntry(): OptionEntry | undefined;
28
+ /**
29
+ * Register an option.
30
+ * @param {OptionEntry} entry Live view of the option’s props.
31
+ * @returns {() => void} Function to unregister the option.
32
+ */
33
+ register(entry: OptionEntry): () => void;
34
+ /**
35
+ * Find the option with the given value.
36
+ * @param {any} value Value to look for.
37
+ * @returns {OptionEntry | undefined} Matching option.
38
+ */
39
+ find(value: any): OptionEntry | undefined;
40
+ /**
41
+ * Mark the option with the given value as the only selected one.
42
+ * @param {any} value Value to select.
43
+ */
44
+ selectOnly(value: any): void;
45
+ #private;
46
+ }
47
+ export function createOptionRegistry(): OptionRegistry;
48
+ export function getOptionRegistry(): OptionRegistry | undefined;
1
49
  export function getSelectedItemDetail(target: HTMLElement): SelectedItemDetail;
50
+ import type { OptionEntry } from '../typedefs';
2
51
  import type { SelectedItemDetail } from '../typedefs';
@@ -1,6 +1,104 @@
1
+ import { getContext, setContext } from 'svelte';
2
+
3
+ /**
4
+ * @import { OptionEntry, SelectedItemDetail } from '../typedefs';
5
+ */
6
+
7
+ /**
8
+ * Context key for {@link OptionRegistry}.
9
+ */
10
+ const CONTEXT_KEY = Symbol('sui-option-registry');
11
+
12
+ /**
13
+ * Reactive registry of the `<Option>`s declared within a `<Combobox>`.
14
+ *
15
+ * The options only render their DOM while the dropdown is expanded, so a collapsed combobox can’t
16
+ * look them up with `querySelector`. Each `<Option>` instead registers a live view of its own props
17
+ * here, which lets the combobox resolve the current label and the initially selected value without
18
+ * keeping the option elements in the DOM tree.
19
+ *
20
+ * The order of the registered options is not significant. Svelte still renders the options itself,
21
+ * in declaration order, so nothing here has to reproduce that order.
22
+ */
23
+ export class OptionRegistry {
24
+ /**
25
+ * Whether the dropdown is expanded, meaning the options should render their DOM.
26
+ * @type {boolean}
27
+ */
28
+ expanded = $state(false);
29
+
30
+ /**
31
+ * Registered options. This is a raw state so that the entries, which expose their properties as
32
+ * getters onto the `<Option>`’s props, are not wrapped in a reactive proxy.
33
+ * @type {OptionEntry[]}
34
+ */
35
+ #entries = $state.raw([]);
36
+
37
+ /**
38
+ * Number of registered options.
39
+ * @returns {number} Count.
40
+ */
41
+ get count() {
42
+ return this.#entries.length;
43
+ }
44
+
45
+ /**
46
+ * The option that is currently marked as selected, if any.
47
+ * @returns {OptionEntry | undefined} Matching option.
48
+ */
49
+ get selectedEntry() {
50
+ return this.#entries.find((entry) => entry.selected);
51
+ }
52
+
53
+ /**
54
+ * Register an option.
55
+ * @param {OptionEntry} entry Live view of the option’s props.
56
+ * @returns {() => void} Function to unregister the option.
57
+ */
58
+ register(entry) {
59
+ this.#entries = [...this.#entries, entry];
60
+
61
+ return () => {
62
+ this.#entries = this.#entries.filter((item) => item !== entry);
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Find the option with the given value.
68
+ * @param {any} value Value to look for.
69
+ * @returns {OptionEntry | undefined} Matching option.
70
+ */
71
+ find(value) {
72
+ return this.#entries.find((entry) => entry.value === value);
73
+ }
74
+
75
+ /**
76
+ * Mark the option with the given value as the only selected one.
77
+ * @param {any} value Value to select.
78
+ */
79
+ selectOnly(value) {
80
+ this.#entries.forEach((entry) => {
81
+ const selected = entry.value === value;
82
+
83
+ if (entry.selected !== selected) {
84
+ entry.selected = selected;
85
+ }
86
+ });
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Create an option registry and provide it to the descendant `<Option>`s.
92
+ * @returns {OptionRegistry} New registry.
93
+ */
94
+ export const createOptionRegistry = () => setContext(CONTEXT_KEY, new OptionRegistry());
95
+
1
96
  /**
2
- * @import { SelectedItemDetail } from '../typedefs';
97
+ * Get the option registry provided by an ancestor `<Combobox>`.
98
+ * @returns {OptionRegistry | undefined} Registry, or `undefined` when the `<Option>` is used
99
+ * standalone within a `<Listbox>`.
3
100
  */
101
+ export const getOptionRegistry = () => getContext(CONTEXT_KEY);
4
102
 
5
103
  /**
6
104
  * Get the detail of the selected element.
@@ -148,8 +148,9 @@ export type ModalProps = {
148
148
  */
149
149
  escapeDismiss?: boolean | undefined;
150
150
  /**
151
- * Whether to keep the content in the DOM tree when the modal is
152
- * not displayed.
151
+ * Whether to keep the `<dialog>` element and its content in the
152
+ * DOM tree while the modal is not displayed. By default, these are mounted only while the modal is
153
+ * open, and unmounted once the closing transition is complete.
153
154
  */
154
155
  keepContent?: boolean | undefined;
155
156
  /**
@@ -351,6 +352,36 @@ export type MenuItemProps = {
351
352
  */
352
353
  onSelect?: ((event: CustomEvent) => void) | undefined;
353
354
  };
355
+ /**
356
+ * A live view of an `<Option>`’s props, registered with the ancestor `<Combobox>` so that it can
357
+ * resolve labels and the initial value while the options are not rendered.
358
+ */
359
+ export type OptionEntry = {
360
+ /**
361
+ * The option’s value.
362
+ */
363
+ value: any;
364
+ /**
365
+ * The option’s text label.
366
+ */
367
+ label: string;
368
+ /**
369
+ * The option’s name.
370
+ */
371
+ name?: string | undefined;
372
+ /**
373
+ * Data type of the `value`.
374
+ */
375
+ type: string;
376
+ /**
377
+ * Whether the option is selected. Writable.
378
+ */
379
+ selected: boolean;
380
+ /**
381
+ * Whether the option is disabled.
382
+ */
383
+ disabled: boolean;
384
+ };
354
385
  export type ComboboxProps = {
355
386
  /**
356
387
  * The `class` attribute on the wrapper element.
@@ -683,6 +714,11 @@ export type TextEditorComponent = {
683
714
  * Material Symbols icon name.
684
715
  */
685
716
  icon?: string | undefined;
717
+ /**
718
+ * Trigger UI of the component. Default: `menuitem`. A
719
+ * menu item is placed under the Insert menu, while a button is placed directly on the toolbar.
720
+ */
721
+ trigger?: "button" | "menuitem" | undefined;
686
722
  /**
687
723
  * Lexical node class implementation.
688
724
  */
package/dist/typedefs.js CHANGED
@@ -54,8 +54,9 @@
54
54
  * @property {boolean} [lightDismiss] Whether to close the modal when the backdrop (outside of the
55
55
  * modal) is clicked.
56
56
  * @property {boolean} [escapeDismiss] Whether to close the modal when the Escape key is pressed.
57
- * @property {boolean} [keepContent] Whether to keep the content in the DOM tree when the modal is
58
- * not displayed.
57
+ * @property {boolean} [keepContent] Whether to keep the `<dialog>` element and its content in the
58
+ * DOM tree while the modal is not displayed. By default, these are mounted only while the modal is
59
+ * open, and unmounted once the closing transition is complete.
59
60
  * @property {HTMLDialogElement} [dialog] A reference to the `<dialog>` element.
60
61
  * @property {Snippet} [children] Primary slot content.
61
62
  * @property {Snippet} [extraContent] Extra slot content.
@@ -122,6 +123,18 @@
122
123
  * @property {(event: CustomEvent) => void} [onSelect] Custom `Select` event handler.
123
124
  */
124
125
 
126
+ /**
127
+ * A live view of an `<Option>`’s props, registered with the ancestor `<Combobox>` so that it can
128
+ * resolve labels and the initial value while the options are not rendered.
129
+ * @typedef {object} OptionEntry
130
+ * @property {any} value The option’s value.
131
+ * @property {string} label The option’s text label.
132
+ * @property {string} [name] The option’s name.
133
+ * @property {string} type Data type of the `value`.
134
+ * @property {boolean} selected Whether the option is selected. Writable.
135
+ * @property {boolean} disabled Whether the option is disabled.
136
+ */
137
+
125
138
  /**
126
139
  * @typedef {object} ComboboxProps
127
140
  * @property {string} [class] The `class` attribute on the wrapper element.
@@ -284,6 +297,8 @@
284
297
  * @property {string} id Component ID.
285
298
  * @property {string} label Component label.
286
299
  * @property {string} [icon] Material Symbols icon name.
300
+ * @property {'menuitem' | 'button'} [trigger] Trigger UI of the component. Default: `menuitem`. A
301
+ * menu item is placed under the Insert menu, while a button is placed directly on the toolbar.
287
302
  * @property {LexicalNode} node Lexical node class implementation.
288
303
  * @property {(props?: Record<string, any>) => LexicalNode} createNode Function to create a new node
289
304
  * instance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,8 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "svelte": "./dist/index.js",
20
20
  "default": "./dist/index.js"
21
- }
21
+ },
22
+ "./locales/*": "./dist/locales/*"
22
23
  },
23
24
  "svelte": "./dist/index.js",
24
25
  "typesVersions": {
@@ -66,9 +67,9 @@
66
67
  "eslint-plugin-package-json": "^1.6.3",
67
68
  "eslint-plugin-svelte": "^3.22.0",
68
69
  "globals": "^17.9.0",
69
- "happy-dom": "^20.11.1",
70
+ "happy-dom": "^20.11.2",
70
71
  "oxlint": "^1.77.0",
71
- "postcss": "^8.5.25",
72
+ "postcss": "^8.5.26",
72
73
  "postcss-html": "^2.0.0",
73
74
  "prettier": "^3.9.6",
74
75
  "prettier-plugin-svelte": "^4.1.1",
@@ -77,10 +78,10 @@
77
78
  "stylelint-config-recommended-scss": "^17.0.1",
78
79
  "stylelint-scss": "^7.2.0",
79
80
  "svelte": "^5.56.8",
80
- "svelte-check": "^4.7.4",
81
+ "svelte-check": "^4.7.5",
81
82
  "svelte-preprocess": "^6.0.5",
82
83
  "tslib": "^2.8.1",
83
- "vite": "^8.2.0",
84
+ "vite": "^8.2.1",
84
85
  "vitest": "^4.1.10"
85
86
  },
86
87
  "peerDependencies": {