@sveltia/ui 0.55.1 → 0.57.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: Annuleren
4
+ close: Sluiten
5
+
6
+ # Button to clear content, e.g. search input or calendar date
7
+ clear: Wissen
8
+
9
+ # Text editor dialog buttons for link/image insertion
10
+ insert: Invoegen
11
+ update: Bijwerken
12
+ remove: Verwijderen
13
+
14
+ # Combobox dropdown toggle button aria-labels
15
+ collapse: Samenvouwen
16
+ expand: Uitvouwen
17
+
18
+ # Infobar/toast dismiss button label
19
+ dismiss: Sluiten
20
+
21
+ # Calendar component
22
+ calendar:
23
+ # View switcher group aria-labels and navigation button aria-labels
24
+ year: Jaar
25
+ previous_decade: Vorig decennium
26
+ next_decade: Volgend decennium
27
+ month: Maand
28
+ previous_month: Vorige maand
29
+ next_month: Volgende maand
30
+ # “Today” button label in footer
31
+ today: Vandaag
32
+
33
+ # Split button component
34
+ split_button:
35
+ # Wrapper aria-label, e.g. “Save Options” when primary button is “Save”
36
+ x_options: 'Opties voor {$name}'
37
+ # Dropdown toggle aria-label
38
+ more_options: Meer opties
39
+
40
+ # Combobox component
41
+ combobox:
42
+ # Placeholder when no option is selected
43
+ select_an_option: Selecteer een optie…
44
+ # Filter input aria-label
45
+ filter_options: Opties filteren
46
+ # Empty state message when filter has no matches
47
+ no_matching_options: Geen overeenkomende opties gevonden
48
+
49
+ # Number input component
50
+ number_input:
51
+ # Spin button aria-labels for incrementing/decrementing the number value
52
+ increase: Verhogen
53
+ decrease: Verlagen
54
+
55
+ # Password input component
56
+ password_input:
57
+ # Visibility toggle button aria-labels
58
+ show_password: Wachtwoord tonen
59
+ hide_password: Wachtwoord verbergen
60
+
61
+ # Secret input component (API keys, tokens, etc.)
62
+ secret_input:
63
+ # Visibility toggle button aria-labels
64
+ show_secret: Secret tonen
65
+ hide_secret: Secret verbergen
66
+
67
+ # Select tags component (multi-select)
68
+ select_tags:
69
+ # Listbox aria-label
70
+ selected_options: Geselecteerde opties
71
+ # Remove button aria-label, e.g. “Remove Option 1”
72
+ remove_x: '{$name} verwijderen'
73
+
74
+ # Text editor component
75
+ text_editor:
76
+ # Toolbar aria-labels
77
+ text_editor: Teksteditor
78
+ code_editor: Code-editor
79
+ # Block style menu button and menu aria-labels
80
+ text_style_options: Tekststijlopties
81
+ show_text_style_options: Tekststijlopties tonen
82
+ # Block style menu items
83
+ paragraph: Alinea
84
+ heading_1: Kop 1
85
+ heading_2: Kop 2
86
+ heading_3: Kop 3
87
+ heading_4: Kop 4
88
+ heading_5: Kop 5
89
+ heading_6: Kop 6
90
+ bulleted_list: Opsommingslijst
91
+ numbered_list: Genummerde lijst
92
+ blockquote: Citaatblok
93
+ code_block: Codeblok
94
+ # Inline style button aria-labels
95
+ bold: Vet
96
+ italic: Cursief
97
+ strikethrough: Doorhalen
98
+ code: Code
99
+ link: Link
100
+ # Link dialog title
101
+ insert_link: Link invoegen
102
+ update_link: Link bijwerken
103
+ # Link dialog input field labels
104
+ text: Tekst
105
+ url: URL
106
+ # Markdown mode toggle button aria-label
107
+ edit_in_markdown: Bewerken in Markdown
108
+ # Error message when rich text conversion fails
109
+ converter_error: De rich-text-modus kan niet worden ingeschakeld. Gebruik in plaats daarvan de platteteksteditor.
110
+ # Language selector aria-label
111
+ language: Taal
112
+ # Plain text mode option in language selector
113
+ plain_text: Platte tekst
@@ -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