@sveltia/ui 0.65.4 → 0.66.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.
@@ -74,12 +74,22 @@
74
74
  return;
75
75
  }
76
76
 
77
- if (focusInput) {
78
- /** @type {HTMLInputElement | HTMLButtonElement} */ (
79
- content?.querySelector('input, button.primary')
80
- )?.focus();
81
- /** @type {HTMLInputElement} */ (content?.querySelector('input'))?.select();
82
- } else {
77
+ const target = focusInput
78
+ ? /** @type {HTMLInputElement | HTMLButtonElement | null} */ (
79
+ content?.querySelector('input, button.primary')
80
+ )
81
+ : null;
82
+
83
+ if (target) {
84
+ target.focus();
85
+
86
+ if (target instanceof HTMLInputElement) {
87
+ target.select();
88
+ }
89
+ } else if (!focusInput || !content?.contains(document.activeElement)) {
90
+ // Fall back to the `<dialog>` element itself, so the focus is never left outside the
91
+ // modal, e.g. when the dialog has no input field or primary button. Content that has
92
+ // already taken the focus is left alone.
83
93
  modal?.focus();
84
94
  }
85
95
  })();
@@ -11,7 +11,7 @@
11
11
  -->
12
12
  <script>
13
13
  import { _ } from '@sveltia/i18n';
14
- import { onMount } from 'svelte';
14
+ import { onMount, untrack } from 'svelte';
15
15
  import { searchEmojis } from './emoji.js';
16
16
 
17
17
  /**
@@ -156,11 +156,19 @@
156
156
  * closed until they move on to another shortcode.
157
157
  */
158
158
  export const close = (dismissed = false) => {
159
- dismissedShortcodeId = dismissed && trigger ? trigger.id : undefined;
160
- trigger = undefined;
161
- candidates = [];
162
- selectedIndex = 0;
163
- anchorRect = undefined;
159
+ // The state is reset within `untrack()`, because a host closes the list when its field loses
160
+ // the focus, and the browser fires that `blur` synchronously while it removes the field from
161
+ // the DOM — which is to say, in the middle of Svelte rendering the block the field lives in,
162
+ // where a plain assignment would be reported as an unsafe mutation. The same goes for a host
163
+ // closing the list from an effect teardown. Not tracking the reads is what’s wanted anyway:
164
+ // whatever happens to be rendering has no business depending on this state.
165
+ untrack(() => {
166
+ dismissedShortcodeId = dismissed && trigger ? trigger.id : undefined;
167
+ trigger = undefined;
168
+ candidates = [];
169
+ selectedIndex = 0;
170
+ anchorRect = undefined;
171
+ });
164
172
  };
165
173
 
166
174
  /**
@@ -7,7 +7,7 @@ export const SHIKI_VERSION: "4.4.3";
7
7
  /**
8
8
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
9
9
  */
10
- export const UI_VERSION: "0.65.4";
10
+ export const UI_VERSION: "0.66.0";
11
11
  /**
12
12
  * Available syntax highlighting languages, sorted by display name.
13
13
  * @type {{ id: string, name: string, aliases?: string[] }[]}
@@ -10,7 +10,7 @@ export const SHIKI_VERSION = "4.4.3";
10
10
  /**
11
11
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
12
12
  */
13
- export const UI_VERSION = "0.65.4";
13
+ export const UI_VERSION = "0.66.0";
14
14
 
15
15
  /**
16
16
  * Available syntax highlighting languages, sorted by display name.
@@ -53,7 +53,8 @@
53
53
  } = $props();
54
54
 
55
55
  /**
56
- * Focus the `<dialog>` element.
56
+ * Focus the `<dialog>` element. It has `tabindex="-1"`, so it can receive focus programmatically,
57
+ * allowing assistive technology to announce the modal’s label and description.
57
58
  */
58
59
  export const focus = () => {
59
60
  dialog?.focus();
@@ -144,6 +145,35 @@
144
145
  */
145
146
  let generation = 0;
146
147
 
148
+ /**
149
+ * The element that had the focus just before the modal was opened. The focus is moved back to it
150
+ * once the modal is closed.
151
+ * @type {HTMLElement | undefined}
152
+ */
153
+ let lastActiveElement;
154
+
155
+ /**
156
+ * Move the focus back to the element that had it before the modal was opened. This is done
157
+ * manually rather than relying on the browser’s own focus restoration, because the modal is
158
+ * closed while `<body>` is `inert`, which prevents the focus from being restored.
159
+ */
160
+ const restoreFocus = () => {
161
+ const { activeElement } = document;
162
+ const element = lastActiveElement;
163
+
164
+ lastActiveElement = undefined;
165
+
166
+ if (!element?.isConnected) {
167
+ return;
168
+ }
169
+
170
+ // Only take the focus back if it’s still inside the modal, or nowhere because the modal took it
171
+ // down with itself. If it has already moved on, pulling it back would undo what happened.
172
+ if (!activeElement || activeElement === document.body || dialog?.contains(activeElement)) {
173
+ element.focus();
174
+ }
175
+ };
176
+
147
177
  /**
148
178
  * Get the longest time from a computed CSS time list, such as `transition-duration`.
149
179
  * @param {string} value Comma-separated CSS time values in seconds, e.g. `0.4s, 0.15s`.
@@ -203,6 +233,9 @@
203
233
  generation += 1;
204
234
 
205
235
  const gen = generation;
236
+ const { activeElement } = document;
237
+
238
+ lastActiveElement = activeElement instanceof HTMLElement ? activeElement : undefined;
206
239
 
207
240
  onOpening?.(new CustomEvent('Opening'));
208
241
  visible = true;
@@ -217,6 +250,18 @@
217
250
  // because the element may have just been added to the DOM tree.
218
251
  dialog.getBoundingClientRect();
219
252
  setOpenClass = true;
253
+ // Wait for the `inert` attribute to be removed, then move the focus into the modal. The
254
+ // browser’s own dialog focusing steps don’t do this, because the element is still `inert` when
255
+ // `showModal()` is called above, leaving the focus on `<body>`. A component using this modal,
256
+ // such as `<Dialog>`, may then move the focus to a specific control, like an input field.
257
+ await tick();
258
+
259
+ if (gen !== generation || !dialog) return;
260
+
261
+ if (!dialog.contains(document.activeElement)) {
262
+ focus();
263
+ }
264
+
220
265
  await waitForTransition();
221
266
  if (gen !== generation) return;
222
267
  setActiveClass = true;
@@ -246,6 +291,7 @@
246
291
  document.body.inert = false;
247
292
  }
248
293
 
294
+ restoreFocus();
249
295
  setActiveClass = false;
250
296
  setOpenClass = false;
251
297
 
@@ -328,6 +374,7 @@
328
374
  {#if mounted}
329
375
  <dialog
330
376
  bind:this={dialog}
377
+ tabindex="-1"
331
378
  {...restProps}
332
379
  inert={!setOpenClass}
333
380
  {role}
@@ -0,0 +1,116 @@
1
+ # Dialog action button labels
2
+ ok: OK
3
+ cancel: Annuller
4
+ close: Luk
5
+
6
+ # Button to clear content, e.g. search input or calendar date
7
+ clear: Ryd
8
+
9
+ # Text editor dialog buttons for link/image insertion
10
+ insert: Indsæt
11
+ update: Opdater
12
+ remove: Fjern
13
+
14
+ # Combobox dropdown toggle button aria-labels
15
+ collapse: Fold sammen
16
+ expand: Fold ud
17
+
18
+ # Infobar/toast dismiss button label
19
+ dismiss: Afvis
20
+
21
+ # Emoji suggestion listbox aria-label, shared by the text fields and the text editor
22
+ emoji_suggestions: Emojiforslag
23
+
24
+ # Calendar component
25
+ calendar:
26
+ # View switcher group aria-labels and navigation button aria-labels
27
+ year: År
28
+ previous_decade: Forrige årti
29
+ next_decade: Næste årti
30
+ month: Måned
31
+ previous_month: Forrige måned
32
+ next_month: Næste måned
33
+ # “Today” button label in footer
34
+ today: I dag
35
+
36
+ # Split button component
37
+ split_button:
38
+ # Wrapper aria-label, e.g. “Save Options” when primary button is “Save”
39
+ x_options: 'Muligheder for {$name}'
40
+ # Dropdown toggle aria-label
41
+ more_options: Flere muligheder
42
+
43
+ # Combobox component
44
+ combobox:
45
+ # Placeholder when no option is selected
46
+ select_an_option: Vælg en mulighed…
47
+ # Filter input aria-label
48
+ filter_options: Filtrér muligheder
49
+ # Empty state message when filter has no matches
50
+ no_matching_options: Ingen matchende muligheder fundet
51
+
52
+ # Number input component
53
+ number_input:
54
+ # Spin button aria-labels for incrementing/decrementing the number value
55
+ increase: Forøg
56
+ decrease: Formindsk
57
+
58
+ # Password input component
59
+ password_input:
60
+ # Visibility toggle button aria-labels
61
+ show_password: Vis adgangskode
62
+ hide_password: Skjul adgangskode
63
+
64
+ # Secret input component (API keys, tokens, etc.)
65
+ secret_input:
66
+ # Visibility toggle button aria-labels
67
+ show_secret: Vis hemmelighed
68
+ hide_secret: Skjul hemmelighed
69
+
70
+ # Select tags component (multi-select)
71
+ select_tags:
72
+ # Listbox aria-label
73
+ selected_options: Valgte muligheder
74
+ # Remove button aria-label, e.g. “Remove Option 1”
75
+ remove_x: 'Fjern {$name}'
76
+
77
+ # Text editor component
78
+ text_editor:
79
+ # Toolbar aria-labels
80
+ text_editor: Teksteditor
81
+ code_editor: Kodeeditor
82
+ # Block style menu button and menu aria-labels
83
+ text_style_options: Muligheder for tekststil
84
+ show_text_style_options: Vis muligheder for tekststil
85
+ # Block style menu items
86
+ paragraph: Afsnit
87
+ heading_1: Overskrift 1
88
+ heading_2: Overskrift 2
89
+ heading_3: Overskrift 3
90
+ heading_4: Overskrift 4
91
+ heading_5: Overskrift 5
92
+ heading_6: Overskrift 6
93
+ bulleted_list: Punktopstilling
94
+ numbered_list: Nummereret liste
95
+ blockquote: Citat
96
+ code_block: Kodeblok
97
+ # Inline style button aria-labels
98
+ bold: Fed
99
+ italic: Kursiv
100
+ strikethrough: Gennemstreget
101
+ code: Kode
102
+ link: Link
103
+ # Link dialog title
104
+ insert_link: Indsæt link
105
+ update_link: Opdater link
106
+ # Link dialog input field labels
107
+ text: Tekst
108
+ url: URL
109
+ # Markdown mode toggle button aria-label
110
+ edit_in_markdown: Rediger i Markdown
111
+ # Error message when rich text conversion fails
112
+ converter_error: Formateret tekst kunne ikke slås til. Brug editoren til ren tekst i stedet.
113
+ # Language selector aria-label
114
+ language: Sprog
115
+ # Plain text mode option in language selector
116
+ plain_text: Ren tekst
@@ -253,7 +253,8 @@ export type DialogProps = {
253
253
  cancelDisabled?: boolean | undefined;
254
254
  /**
255
255
  * Whether to automatically focus the first input field or primary
256
- * action button. Default: `true`. If `false`, the `<dialog>` gets focused.
256
+ * action button. Default: `true`. If `false`, or if the dialog has neither, the `<dialog>` element
257
+ * gets focused instead, so the focus is always moved into the modal.
257
258
  */
258
259
  focusInput?: boolean | undefined;
259
260
  /**
package/dist/typedefs.js CHANGED
@@ -89,7 +89,8 @@
89
89
  * `Control` or `Meta` depending on the user’s operating system.
90
90
  * @property {boolean} [cancelDisabled] Whether to disable the Cancel button.
91
91
  * @property {boolean} [focusInput] Whether to automatically focus the first input field or primary
92
- * action button. Default: `true`. If `false`, the `<dialog>` gets focused.
92
+ * action button. Default: `true`. If `false`, or if the dialog has neither, the `<dialog>` element
93
+ * gets focused instead, so the focus is always moved into the modal.
93
94
  * @property {boolean} [lightDismiss] Whether to close the modal when the backdrop (outside of the
94
95
  * modal) is clicked.
95
96
  * @property {string} [value] Value entered on the textbox.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.65.4",
3
+ "version": "0.66.0",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,7 +62,7 @@
62
62
  "eslint-config-airbnb-extended": "^3.2.0",
63
63
  "eslint-config-prettier": "^10.1.8",
64
64
  "eslint-plugin-import": "^2.32.0",
65
- "eslint-plugin-jsdoc": "^64.1.0",
65
+ "eslint-plugin-jsdoc": "^64.2.0",
66
66
  "eslint-plugin-package-json": "^1.7.1",
67
67
  "eslint-plugin-svelte": "^3.23.0",
68
68
  "globals": "^17.11.0",