@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.
@@ -2,9 +2,25 @@
2
2
  @component
3
3
  A generic modal top-layer helper based on the HTML `<dialog>` element.
4
4
  -->
5
+ <script module>
6
+ /**
7
+ * Context key used by a modal to keep the modal it’s rendered within mounted.
8
+ * @type {symbol}
9
+ */
10
+ const RETAINER_KEY = Symbol('sui-modal-retainer');
11
+ </script>
12
+
5
13
  <script>
6
- import { sleep } from '@sveltia/utils/misc';
7
- import { mount, onMount, unmount } from 'svelte';
14
+ import {
15
+ getAllContexts,
16
+ getContext,
17
+ mount,
18
+ onMount,
19
+ setContext,
20
+ tick,
21
+ unmount,
22
+ untrack,
23
+ } from 'svelte';
8
24
  import Placeholder from './placeholder.svelte';
9
25
 
10
26
  /**
@@ -58,7 +74,67 @@
58
74
 
59
75
  let setOpenClass = $state(false);
60
76
  let setActiveClass = $state(false);
61
- let showContent = $state(false);
77
+ /**
78
+ * Whether the modal is being displayed. This is enabled just before the opening transition
79
+ * starts, and disabled right after the closing transition is complete.
80
+ * @type {boolean}
81
+ */
82
+ let visible = $state(false);
83
+
84
+ /**
85
+ * The number of descendant modals that are currently being displayed. A popup’s content is
86
+ * unmounted as soon as the popup closes, which would also destroy any modal declared alongside
87
+ * it, such as a dialog opened from a menu item. Because the menu item inevitably goes away with
88
+ * the menu, the dialog has to outlive it, so the content is held until the dialog is done with.
89
+ * @type {number}
90
+ */
91
+ let retainCount = $state(0);
92
+
93
+ /**
94
+ * The retainer of the modal this modal is rendered within, if any. This has to be read before the
95
+ * `setContext()` call below, which would otherwise shadow it with this modal’s own retainer.
96
+ * @type {{ retain: () => void, release: () => void } | undefined}
97
+ */
98
+ const parentRetainer = getContext(RETAINER_KEY);
99
+
100
+ // This has to be set before `getAllContexts()` below, so the content, which is rendered in a
101
+ // separate component tree, can reach it. The counter is updated within `untrack()`, because
102
+ // incrementing it reads it first, which would otherwise make it a dependency of the calling
103
+ // descendant’s effect and send that effect into an endless retain/release loop.
104
+ setContext(RETAINER_KEY, {
105
+ /**
106
+ * Keep this modal mounted on behalf of a descendant modal.
107
+ */
108
+ retain: () => {
109
+ untrack(() => {
110
+ retainCount += 1;
111
+ });
112
+ },
113
+ /**
114
+ * Release a hold previously acquired with `retain`.
115
+ */
116
+ release: () => {
117
+ untrack(() => {
118
+ retainCount -= 1;
119
+ });
120
+ },
121
+ });
122
+
123
+ /**
124
+ * Whether the `<dialog>` element is in the DOM tree. Unless {@link keepContent} is enabled, the
125
+ * element is mounted on demand, and unmounted once the closing transition is complete.
126
+ * @type {boolean}
127
+ */
128
+ const mounted = $derived(keepContent || visible || retainCount > 0);
129
+
130
+ /**
131
+ * Whether the modal has been requested to open. Unlike the {@link open} prop, this is a plain
132
+ * variable updated synchronously at the very beginning of `openDialog`/`closeDialog`, so either
133
+ * of them can bail out early when the requested state is already in effect.
134
+ * @type {boolean}
135
+ */
136
+ let requestedOpen = false;
137
+
62
138
  /**
63
139
  * Monotonically increasing counter used to detect stale async operations. Incremented at the
64
140
  * start of each `openDialog`/`closeDialog` call; any suspended continuation that finds its
@@ -68,44 +144,78 @@
68
144
  */
69
145
  let generation = 0;
70
146
 
147
+ /**
148
+ * Get the longest time from a computed CSS time list, such as `transition-duration`.
149
+ * @param {string} value Comma-separated CSS time values in seconds, e.g. `0.4s, 0.15s`.
150
+ * @returns {number} Time in milliseconds.
151
+ */
152
+ const getLongestTime = (value) =>
153
+ Math.max(0, ...value.split(',').map((time) => Number.parseFloat(time) || 0)) * 1000;
154
+
71
155
  /**
72
156
  * Resolve once the transition is complete.
73
157
  * @returns {Promise<void>} Nothing.
74
158
  */
75
- const waitForTransition = async () =>
76
- new Promise((resolve) => {
77
- /**
78
- * Transition event listener.
79
- * @param {TransitionEvent} event `transition` event.
80
- */
81
- const listener = (event) => {
159
+ const waitForTransition = async () => {
160
+ // Let the CSS class change be applied first, so the duration below is read from the new state
161
+ await tick();
162
+
163
+ if (!dialog) {
164
+ return;
165
+ }
166
+
167
+ const { transitionDuration, transitionDelay } = getComputedStyle(dialog);
168
+ // Fall back to a timer, so the modal is never stuck half-open (and, more importantly, never
169
+ // left mounted) in case `transitionend` is never fired, e.g. when the transition is removed by
170
+ // the consumer’s CSS or the element is not rendered at all
171
+ const timeout = getLongestTime(transitionDuration) + getLongestTime(transitionDelay) + 100;
172
+ const controller = new AbortController();
173
+ const { signal } = controller;
174
+
175
+ dialog.addEventListener(
176
+ 'transitionend',
177
+ (event) => {
82
178
  if (event.target === dialog) {
83
- dialog.removeEventListener('transitionend', listener);
84
- resolve();
179
+ controller.abort();
85
180
  }
86
- };
181
+ },
182
+ { signal },
183
+ );
87
184
 
88
- dialog?.addEventListener('transitionend', listener);
185
+ const timer = window.setTimeout(() => controller.abort(), timeout);
186
+
187
+ await new Promise((resolve) => {
188
+ signal.addEventListener('abort', () => resolve(undefined));
89
189
  });
90
190
 
191
+ window.clearTimeout(timer);
192
+ };
193
+
91
194
  /**
92
195
  * Show the modal.
93
196
  */
94
197
  const openDialog = async () => {
95
- if (!dialog || dialog?.open) {
198
+ if (requestedOpen) {
96
199
  return;
97
200
  }
98
201
 
202
+ requestedOpen = true;
99
203
  generation += 1;
100
204
 
101
205
  const gen = generation;
102
206
 
103
207
  onOpening?.(new CustomEvent('Opening'));
104
- showContent = true;
208
+ visible = true;
209
+ // Wait for the `<dialog>` element to be added to the DOM tree
210
+ await tick();
211
+
212
+ if (gen !== generation || !dialog || dialog.open) return;
213
+
105
214
  dialog.showModal();
106
215
  onOpen?.(new CustomEvent('Open'));
107
- await sleep(0);
108
- if (gen !== generation) return;
216
+ // Force a style recalculation, so the browser has a state to transition from. This is required
217
+ // because the element may have just been added to the DOM tree.
218
+ dialog.getBoundingClientRect();
109
219
  setOpenClass = true;
110
220
  await waitForTransition();
111
221
  if (gen !== generation) return;
@@ -116,21 +226,26 @@
116
226
  * Hide the modal.
117
227
  */
118
228
  const closeDialog = async () => {
119
- if (!dialog || !dialog.open) {
229
+ if (!requestedOpen) {
120
230
  return;
121
231
  }
122
232
 
233
+ requestedOpen = false;
123
234
  generation += 1;
124
235
 
125
236
  const gen = generation;
126
237
  const wasOpen = setOpenClass;
127
- const { returnValue } = dialog;
238
+ const returnValue = dialog?.returnValue ?? '';
128
239
 
129
240
  onClosing?.(new CustomEvent('Closing'));
130
- // Prevent a button behind the `<dialog>` from being clicked erroneously (Svelte bug)
131
- document.body.inert = true;
132
- dialog.close();
133
- document.body.inert = false;
241
+
242
+ if (dialog?.open) {
243
+ // Prevent a button behind the `<dialog>` from being clicked erroneously (Svelte bug)
244
+ document.body.inert = true;
245
+ dialog.close();
246
+ document.body.inert = false;
247
+ }
248
+
134
249
  setActiveClass = false;
135
250
  setOpenClass = false;
136
251
 
@@ -143,7 +258,8 @@
143
258
 
144
259
  if (gen !== generation) return;
145
260
 
146
- showContent = false;
261
+ // Unmount the `<dialog>` element unless `keepContent` is enabled
262
+ visible = false;
147
263
 
148
264
  if (returnValue === 'ok') {
149
265
  onOk?.(new CustomEvent('Ok'));
@@ -154,22 +270,26 @@
154
270
  }
155
271
 
156
272
  onClose?.(new CustomEvent('Close', { detail: { returnValue } }));
157
- dialog.returnValue = '';
158
- };
159
273
 
160
- $effect(() => {
161
- if (open) {
162
- openDialog();
163
- } else {
164
- closeDialog();
274
+ if (dialog) {
275
+ dialog.returnValue = '';
165
276
  }
166
- });
277
+ };
278
+
279
+ /**
280
+ * The context available to this component. The `<dialog>` element is rendered in a separate
281
+ * component tree created with `mount()`, which would otherwise start with an empty context, so
282
+ * this is forwarded to keep `getContext()` working for the modal content.
283
+ * @type {Map<any, any>}
284
+ */
285
+ const context = getAllContexts();
167
286
 
168
287
  onMount(() => {
169
288
  const placeholder = mount(Placeholder, {
170
289
  target: document.querySelector('.sui.app-shell') ?? document.body,
171
290
  // eslint-disable-next-line no-use-before-define
172
291
  props: { children: dialogSnippet },
292
+ context,
173
293
  });
174
294
 
175
295
  // onUnmount
@@ -178,43 +298,67 @@
178
298
  unmount(placeholder);
179
299
  };
180
300
  });
301
+
302
+ // This must be declared after `onMount()` above, because effects run in declaration order, and
303
+ // `openDialog()` expects the placeholder holding the `<dialog>` element to be already mounted
304
+ $effect(() => {
305
+ if (open) {
306
+ openDialog();
307
+ } else {
308
+ closeDialog();
309
+ }
310
+ });
311
+
312
+ // Hold the enclosing modal, if any, for as long as this one is on screen. `visible` is used
313
+ // rather than `mounted`, so a `keepContent` modal doesn’t pin its ancestor forever
314
+ $effect(() => {
315
+ if (!visible) {
316
+ return undefined;
317
+ }
318
+
319
+ parentRetainer?.retain();
320
+
321
+ return () => {
322
+ parentRetainer?.release();
323
+ };
324
+ });
181
325
  </script>
182
326
 
183
327
  {#snippet dialogSnippet()}
184
- <dialog
185
- bind:this={dialog}
186
- {...restProps}
187
- inert={!setOpenClass}
188
- {role}
189
- class="sui modal {className}"
190
- class:backdrop={showBackdrop}
191
- class:open={setOpenClass}
192
- class:active={setActiveClass}
193
- onclick={({ target }) => {
194
- if (
195
- dialog &&
196
- lightDismiss &&
197
- /** @type {HTMLElement | undefined} */ (target)?.matches('dialog')
198
- ) {
199
- dialog.returnValue = 'cancel';
200
- open = false;
201
- }
202
- }}
203
- oncancel={(event) => {
204
- event.preventDefault();
205
-
206
- // Escape key is pressed
207
- if (dialog && escapeDismiss) {
208
- dialog.returnValue = 'cancel';
209
- open = false;
210
- }
211
- }}
212
- >
213
- {@render extraContent?.()}
214
- {#if showContent || keepContent}
328
+ {#if mounted}
329
+ <dialog
330
+ bind:this={dialog}
331
+ {...restProps}
332
+ inert={!setOpenClass}
333
+ {role}
334
+ class="sui modal {className}"
335
+ class:backdrop={showBackdrop}
336
+ class:open={setOpenClass}
337
+ class:active={setActiveClass}
338
+ onclick={({ target }) => {
339
+ if (
340
+ dialog &&
341
+ lightDismiss &&
342
+ /** @type {HTMLElement | undefined} */ (target)?.matches('dialog')
343
+ ) {
344
+ dialog.returnValue = 'cancel';
345
+ open = false;
346
+ }
347
+ }}
348
+ oncancel={(event) => {
349
+ event.preventDefault();
350
+
351
+ // Escape key is pressed
352
+ if (dialog && escapeDismiss) {
353
+ dialog.returnValue = 'cancel';
354
+ open = false;
355
+ }
356
+ }}
357
+ >
358
+ {@render extraContent?.()}
215
359
  {@render children?.()}
216
- {/if}
217
- </dialog>
360
+ </dialog>
361
+ {/if}
218
362
  {/snippet}
219
363
 
220
364
  <style>dialog {
@@ -55,11 +55,8 @@
55
55
  } = $props();
56
56
 
57
57
  /**
58
- * @type {boolean}
59
- */
60
- let initialized = $state(false);
61
- /**
62
- * A reference to the `<dialog>` element.
58
+ * A reference to the `<dialog>` element. This is only available while the popup is open, because
59
+ * the element is mounted on demand.
63
60
  * @type {HTMLDialogElement | undefined}
64
61
  */
65
62
  let dialogElement = $state();
@@ -78,21 +75,12 @@
78
75
  /**
79
76
  * @type {{ style: { inset: string | undefined, zIndex: number | undefined, minWidth: string |
80
77
  * undefined, maxWidth: string | undefined, height: string | undefined }, open: boolean,
81
- * checkPosition: () => void, destroy: () => void } | undefined}
78
+ * attachPopupElement: (popupElement: HTMLDialogElement, contentElement?: HTMLElement) => void,
79
+ * detachPopupElement: () => void, checkPosition: () => void, destroy: () => void } | undefined}
82
80
  */
83
81
  let popupInstance = $state();
84
82
  let hoveredTimeout = 0;
85
83
 
86
- /**
87
- * Initialize the popup.
88
- */
89
- const init = () => {
90
- popupInstance = activatePopup(anchor, dialogElement, position, positionBaseElement);
91
-
92
- contentType = anchor?.getAttribute('aria-haspopup') ?? undefined;
93
- initialized = true;
94
- };
95
-
96
84
  $effect(() => {
97
85
  if (popupInstance) {
98
86
  open = popupInstance.open;
@@ -106,14 +94,35 @@
106
94
  }
107
95
  });
108
96
 
97
+ // The instance must be created as soon as the anchor is available, without waiting for the
98
+ // `<dialog>` element, because it’s the instance that listens to the anchor and opens the popup —
99
+ // and therefore causes the element to be mounted in the first place
109
100
  $effect(() => {
110
- if (anchor && dialogElement && !initialized) {
111
- init();
101
+ if (anchor && !popupInstance) {
102
+ popupInstance = activatePopup(anchor, undefined, position, positionBaseElement);
103
+ contentType = anchor.getAttribute('aria-haspopup') ?? undefined;
112
104
  }
113
105
  });
114
106
 
107
+ // Attach the `<dialog>` element to the instance whenever it’s mounted, and detach it when it’s
108
+ // unmounted, which happens on every close. The content element is passed alongside it, because
109
+ // a nested popup shares the `<dialog>` with its parent and only the content is its own.
110
+ $effect(() => {
111
+ if (popupInstance && dialogElement && content) {
112
+ popupInstance.attachPopupElement(dialogElement, content);
113
+
114
+ return () => {
115
+ popupInstance?.detachPopupElement();
116
+ };
117
+ }
118
+
119
+ return undefined;
120
+ });
121
+
122
+ // The position can only be calculated once the content is in the DOM tree, so it has to be
123
+ // (re)checked here rather than in the instance’s `open` setter, which runs before the mount
115
124
  $effect(() => {
116
- if (parentDialogElement && open) {
125
+ if (open && dialogElement) {
117
126
  popupInstance?.checkPosition();
118
127
  }
119
128
  });
@@ -175,7 +184,6 @@
175
184
  bind:open
176
185
  showBackdrop={showBackdrop ?? touch}
177
186
  lightDismiss={true}
178
- keepContent={true}
179
187
  onOpen={async (event) => {
180
188
  onOpen?.(event);
181
189
 
@@ -0,0 +1,113 @@
1
+ # Dialog action button labels
2
+ ok: OK
3
+ cancel: Anuluj
4
+ close: Zamknij
5
+
6
+ # Button to clear content, e.g. search input or calendar date
7
+ clear: Wyczyść
8
+
9
+ # Text editor dialog buttons for link/image insertion
10
+ insert: Wstaw
11
+ update: Zaktualizuj
12
+ remove: Usuń
13
+
14
+ # Combobox dropdown toggle button aria-labels
15
+ collapse: Zwiń
16
+ expand: Rozwiń
17
+
18
+ # Infobar/toast dismiss button label
19
+ dismiss: Odrzuć
20
+
21
+ # Calendar component
22
+ calendar:
23
+ # View switcher group aria-labels and navigation button aria-labels
24
+ year: Rok
25
+ previous_decade: Poprzednia dekada
26
+ next_decade: Następna dekada
27
+ month: Miesiąc
28
+ previous_month: Poprzedni miesiąc
29
+ next_month: Następny miesiąc
30
+ # “Today” button label in footer
31
+ today: Dzisiaj
32
+
33
+ # Split button component
34
+ split_button:
35
+ # Wrapper aria-label, e.g. “Save Options” when primary button is “Save”
36
+ x_options: 'Opcje {$name}'
37
+ # Dropdown toggle aria-label
38
+ more_options: Więcej opcji
39
+
40
+ # Combobox component
41
+ combobox:
42
+ # Placeholder when no option is selected
43
+ select_an_option: Wybierz opcję…
44
+ # Filter input aria-label
45
+ filter_options: Filtruj opcje
46
+ # Empty state message when filter has no matches
47
+ no_matching_options: Nie znaleziono pasujących opcji
48
+
49
+ # Number input component
50
+ number_input:
51
+ # Spin button aria-labels for incrementing/decrementing the number value
52
+ increase: Zwiększ
53
+ decrease: Zmniejsz
54
+
55
+ # Password input component
56
+ password_input:
57
+ # Visibility toggle button aria-labels
58
+ show_password: Pokaż hasło
59
+ hide_password: Ukryj hasło
60
+
61
+ # Secret input component (API keys, tokens, etc.)
62
+ secret_input:
63
+ # Visibility toggle button aria-labels
64
+ show_secret: Pokaż sekret
65
+ hide_secret: Ukryj sekret
66
+
67
+ # Select tags component (multi-select)
68
+ select_tags:
69
+ # Listbox aria-label
70
+ selected_options: Wybrane opcje
71
+ # Remove button aria-label, e.g. “Remove Option 1”
72
+ remove_x: 'Usuń {$name}'
73
+
74
+ # Text editor component
75
+ text_editor:
76
+ # Toolbar aria-labels
77
+ text_editor: Edytor tekstu
78
+ code_editor: Edytor kodu
79
+ # Block style menu button and menu aria-labels
80
+ text_style_options: Opcje stylu tekstu
81
+ show_text_style_options: Pokaż opcje stylu tekstu
82
+ # Block style menu items
83
+ paragraph: Akapit
84
+ heading_1: Nagłówek 1
85
+ heading_2: Nagłówek 2
86
+ heading_3: Nagłówek 3
87
+ heading_4: Nagłówek 4
88
+ heading_5: Nagłówek 5
89
+ heading_6: Nagłówek 6
90
+ bulleted_list: Lista punktowana
91
+ numbered_list: Lista numerowana
92
+ blockquote: Cytat blokowy
93
+ code_block: Blok kodu
94
+ # Inline style button aria-labels
95
+ bold: Pogrubienie
96
+ italic: Kursywa
97
+ strikethrough: Przekreślenie
98
+ code: Kod
99
+ link: Odnośnik
100
+ # Link dialog title
101
+ insert_link: Wstaw odnośnik
102
+ update_link: Zaktualizuj odnośnik
103
+ # Link dialog input field labels
104
+ text: Tekst
105
+ url: URL
106
+ # Markdown mode toggle button aria-label
107
+ edit_in_markdown: Edytuj w Markdown
108
+ # Error message when rich text conversion fails
109
+ converter_error: Nie można włączyć trybu tekstu sformatowanego. Zamiast tego użyj zwykłego edytora tekstu.
110
+ # Language selector aria-label
111
+ language: Język
112
+ # Plain text mode option in language selector
113
+ plain_text: Zwykły tekst
@@ -29,10 +29,33 @@ export class Group {
29
29
  childSelectedProp: string;
30
30
  focusChild: boolean;
31
31
  selectFirst: boolean;
32
+ /**
33
+ * Whether a member’s `aria-controls` target is a panel this group owns, as with a tab and its
34
+ * tabpanel. Only then may the group hide the target when the member isn’t selected. Elsewhere
35
+ * `aria-controls` means something quite different — on a menu item it points at the submenu the
36
+ * item opens, and on a toolbar button at the region it acts on — and hiding those would break
37
+ * the very widget the member controls.
38
+ * @type {boolean}
39
+ */
40
+ controlsPanel: boolean;
41
+ /**
42
+ * Which member holds the group’s single tab stop. `selected` suits widgets where one member is
43
+ * the natural entry point, such as the checked radio or the current tab. `first` suits menus,
44
+ * where any number of items can be checked at once, so the checked state says nothing about
45
+ * where the keyboard should land.
46
+ * @type {'selected' | 'first'}
47
+ */
48
+ rovingTabStop: "selected" | "first";
32
49
  /**
33
50
  * Activate the members.
34
51
  */
35
52
  activate(): void;
53
+ /**
54
+ * Put exactly one member in the tab sequence, as a composite widget should. Giving every checked
55
+ * member `tabindex="0"` would scatter tab stops through the widget — a menu with two checked
56
+ * items would take three tab presses to step over.
57
+ */
58
+ updateTabStop(): void;
36
59
  /**
37
60
  * CSS selector to retrieve the members.
38
61
  * @type {string}
@@ -48,6 +71,40 @@ export class Group {
48
71
  * @type {HTMLElement[]}
49
72
  */
50
73
  get activeMembers(): HTMLElement[];
74
+ /**
75
+ * The element that opens this menu, either a menu button or a menu item in the menu above.
76
+ * @type {HTMLElement | null}
77
+ */
78
+ get opener(): HTMLElement | null;
79
+ /**
80
+ * The menu item that opens this menu, when this menu is a submenu. A menu button is deliberately
81
+ * excluded, because closing a top-level menu is not what “back to the parent item” means.
82
+ * @type {HTMLElement | null}
83
+ */
84
+ get parentMenuItem(): HTMLElement | null;
85
+ /**
86
+ * Close this menu along with every menu above it.
87
+ * @returns {HTMLElement | null} The element that opens the outermost menu, so the caller can
88
+ * hand focus back to it.
89
+ */
90
+ closeMenuChain(): HTMLElement | null;
91
+ /**
92
+ * Leave the menu the way Tab should: close it along with every menu above it, then carry focus on
93
+ * to whatever follows the outermost opener. The browser can’t be left to do this itself, because
94
+ * a modal `<dialog>` confines Tab to its own contents.
95
+ * @param {boolean} backwards Whether to move to the previous tab stop instead, as Shift+Tab.
96
+ */
97
+ leaveMenu(backwards: boolean): Promise<void>;
98
+ /**
99
+ * Open the submenu of the given menu item if needed, and move focus onto its first item.
100
+ * @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
101
+ */
102
+ enterSubmenu(item: HTMLElement): Promise<void>;
103
+ /**
104
+ * Close this submenu and move focus back onto the menu item that opens it.
105
+ * @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
106
+ */
107
+ leaveSubmenu(item: HTMLElement): void;
51
108
  /**
52
109
  * Get the currently selected member.
53
110
  * @type {HTMLElement | undefined}