@sveltia/ui 0.65.3 → 0.65.5
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.
- package/dist/components/dialog/dialog.svelte +16 -6
- package/dist/components/emoji/emoji-suggestions.svelte +16 -31
- package/dist/components/emoji/emoji-suggestions.svelte.d.ts +2 -2
- package/dist/components/emoji/emoji.d.ts +0 -1
- package/dist/components/emoji/emoji.js +8 -37
- package/dist/components/text-editor/shiki/generated.d.ts +1 -1
- package/dist/components/text-editor/shiki/generated.js +1 -1
- package/dist/components/util/modal.svelte +48 -1
- package/dist/typedefs.d.ts +2 -1
- package/dist/typedefs.js +2 -1
- package/package.json +2 -2
|
@@ -74,12 +74,22 @@
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
/** @type {HTMLInputElement | HTMLButtonElement} */ (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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,8 +11,8 @@
|
|
|
11
11
|
-->
|
|
12
12
|
<script>
|
|
13
13
|
import { _ } from '@sveltia/i18n';
|
|
14
|
-
import { onMount } from 'svelte';
|
|
15
|
-
import {
|
|
14
|
+
import { onMount, untrack } from 'svelte';
|
|
15
|
+
import { searchEmojis } from './emoji.js';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* @import { EmojiAnchorRect, EmojiEntry, EmojiTrigger } from '../../typedefs';
|
|
@@ -84,12 +84,6 @@
|
|
|
84
84
|
* @type {HTMLElement | undefined}
|
|
85
85
|
*/
|
|
86
86
|
let listElement = $state();
|
|
87
|
-
/**
|
|
88
|
-
* Monotonically increasing counter used to discard the result of a search that has been
|
|
89
|
-
* superseded while the emoji list was being loaded.
|
|
90
|
-
* @type {number}
|
|
91
|
-
*/
|
|
92
|
-
let searchGeneration = 0;
|
|
93
87
|
/**
|
|
94
88
|
* Identifier of the shortcode the user has dismissed with the Escape key, so the suggestions
|
|
95
89
|
* don’t come back while they keep typing it.
|
|
@@ -162,19 +156,26 @@
|
|
|
162
156
|
* closed until they move on to another shortcode.
|
|
163
157
|
*/
|
|
164
158
|
export const close = (dismissed = false) => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
});
|
|
171
172
|
};
|
|
172
173
|
|
|
173
174
|
/**
|
|
174
175
|
* Feed the shortcode being typed into the list, or nothing to close it.
|
|
175
176
|
* @param {EmojiTrigger} [newTrigger] Shortcode state.
|
|
176
177
|
*/
|
|
177
|
-
export const update =
|
|
178
|
+
export const update = (newTrigger) => {
|
|
178
179
|
if (!newTrigger) {
|
|
179
180
|
if (trigger || dismissedShortcodeId) {
|
|
180
181
|
close();
|
|
@@ -202,17 +203,6 @@
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
trigger = newTrigger;
|
|
205
|
-
searchGeneration += 1;
|
|
206
|
-
|
|
207
|
-
const generation = searchGeneration;
|
|
208
|
-
|
|
209
|
-
await loadEmojiList();
|
|
210
|
-
|
|
211
|
-
// Bail out if the user has kept typing in the meantime
|
|
212
|
-
if (generation !== searchGeneration) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
206
|
candidates = searchEmojis(query);
|
|
217
207
|
selectedIndex = 0;
|
|
218
208
|
};
|
|
@@ -359,11 +349,6 @@
|
|
|
359
349
|
});
|
|
360
350
|
|
|
361
351
|
onMount(() => {
|
|
362
|
-
// Warm the data up front, so the first shortcode the user types shows suggestions straight away
|
|
363
|
-
// rather than waiting on the network. The loader memoizes, so this costs one request per page
|
|
364
|
-
// however many fields are on it, and nothing at all once it’s cached.
|
|
365
|
-
loadEmojiList();
|
|
366
|
-
|
|
367
352
|
/**
|
|
368
353
|
* Follow the caret when the page or an ancestor is scrolled or the window is resized.
|
|
369
354
|
*/
|
|
@@ -5,7 +5,7 @@ type EmojiSuggestions = {
|
|
|
5
5
|
} & {
|
|
6
6
|
isOpen: () => boolean;
|
|
7
7
|
close: (dismissed?: boolean | undefined) => void;
|
|
8
|
-
update: (newTrigger?: EmojiTrigger | undefined) =>
|
|
8
|
+
update: (newTrigger?: EmojiTrigger | undefined) => void;
|
|
9
9
|
moveSelection: (delta: number) => void;
|
|
10
10
|
selectHighlighted: () => void;
|
|
11
11
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
|
@@ -39,7 +39,7 @@ declare const EmojiSuggestions: import("svelte").Component<{
|
|
|
39
39
|
}, {
|
|
40
40
|
isOpen: () => boolean;
|
|
41
41
|
close: (dismissed?: boolean | undefined) => void;
|
|
42
|
-
update: (newTrigger?: EmojiTrigger | undefined) =>
|
|
42
|
+
update: (newTrigger?: EmojiTrigger | undefined) => void;
|
|
43
43
|
moveSelection: (delta: number) => void;
|
|
44
44
|
selectHighlighted: () => void;
|
|
45
45
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
|
@@ -16,7 +16,6 @@ export const EMOJI_TRIGGER_REGEX: RegExp;
|
|
|
16
16
|
*/
|
|
17
17
|
export const MAX_EMOJI_SUGGESTIONS: 50;
|
|
18
18
|
export function parseEmojiData(data: string): EmojiEntry[];
|
|
19
|
-
export function loadEmojiList(): Promise<EmojiEntry[]>;
|
|
20
19
|
/**
|
|
21
20
|
* Rank given when there is no match at all. Higher than any real rank, so an unmatched emoji sorts
|
|
22
21
|
* last and the ranks can still be compared arithmetically.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { EMOJI_DATA } from './generated.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @import { EmojiEntry } from '../../typedefs';
|
|
3
5
|
*/
|
|
@@ -24,15 +26,11 @@ export const MAX_EMOJI_SUGGESTIONS = 50;
|
|
|
24
26
|
*/
|
|
25
27
|
const WORD_SEPARATOR_REGEX = /[-_]/;
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
29
|
+
* Emoji list, parsed out of the data the first time it’s searched. This is `undefined` until then,
|
|
30
|
+
* so a page that never sees a shortcode never pays for it.
|
|
28
31
|
* @type {EmojiEntry[] | undefined}
|
|
29
32
|
*/
|
|
30
33
|
let emojiList;
|
|
31
|
-
/**
|
|
32
|
-
* In-flight or completed loader for {@link emojiList}, so the data is only parsed once.
|
|
33
|
-
* @type {Promise<EmojiEntry[]> | undefined}
|
|
34
|
-
*/
|
|
35
|
-
let loader;
|
|
36
34
|
|
|
37
35
|
/**
|
|
38
36
|
* Convert the generated emoji data into a searchable list.
|
|
@@ -54,34 +52,6 @@ export const parseEmojiData = (data) =>
|
|
|
54
52
|
};
|
|
55
53
|
});
|
|
56
54
|
|
|
57
|
-
/**
|
|
58
|
-
* Load the emoji list.
|
|
59
|
-
*
|
|
60
|
-
* The data is imported on demand rather than up front, so a bundler that can split it out keeps it
|
|
61
|
-
* out of the initial payload — most sessions never type a shortcode. A failure here is not worth
|
|
62
|
-
* surfacing: no suggestions are ever shown, and the shortcode the user typed stays as plain text.
|
|
63
|
-
* @returns {Promise<EmojiEntry[]>} Emoji list, or an empty list if the data can’t be obtained.
|
|
64
|
-
*/
|
|
65
|
-
export const loadEmojiList = async () => {
|
|
66
|
-
loader ??= (async () => {
|
|
67
|
-
try {
|
|
68
|
-
const { EMOJI_DATA } = await import('./generated.js');
|
|
69
|
-
|
|
70
|
-
emojiList = parseEmojiData(EMOJI_DATA);
|
|
71
|
-
} catch (ex) {
|
|
72
|
-
// Allow a later attempt to retry, so a transient chunk load failure isn’t permanent
|
|
73
|
-
loader = undefined;
|
|
74
|
-
emojiList = [];
|
|
75
|
-
// eslint-disable-next-line no-console
|
|
76
|
-
console.error(ex);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return /** @type {EmojiEntry[]} */ (emojiList);
|
|
80
|
-
})();
|
|
81
|
-
|
|
82
|
-
return loader;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
55
|
/**
|
|
86
56
|
* Rank given when there is no match at all. Higher than any real rank, so an unmatched emoji sorts
|
|
87
57
|
* last and the ranks can still be compared arithmetically.
|
|
@@ -194,8 +164,7 @@ const getMatchCentrality = ({ name, aliases }, query) => {
|
|
|
194
164
|
};
|
|
195
165
|
|
|
196
166
|
/**
|
|
197
|
-
* Search the
|
|
198
|
-
* {@link loadEmojiList} has been resolved beforehand.
|
|
167
|
+
* Search the emoji list for the given query.
|
|
199
168
|
* @param {string} query Search query without the leading colon, e.g. `smi`.
|
|
200
169
|
* @returns {EmojiEntry[]} Matching emojis, best match first, capped at
|
|
201
170
|
* {@link MAX_EMOJI_SUGGESTIONS}.
|
|
@@ -203,10 +172,12 @@ const getMatchCentrality = ({ name, aliases }, query) => {
|
|
|
203
172
|
export const searchEmojis = (query) => {
|
|
204
173
|
const normalizedQuery = query.toLowerCase();
|
|
205
174
|
|
|
206
|
-
if (!
|
|
175
|
+
if (!normalizedQuery) {
|
|
207
176
|
return [];
|
|
208
177
|
}
|
|
209
178
|
|
|
179
|
+
emojiList ??= parseEmojiData(EMOJI_DATA);
|
|
180
|
+
|
|
210
181
|
return (
|
|
211
182
|
emojiList
|
|
212
183
|
.map((entry) => {
|
|
@@ -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.
|
|
10
|
+
export const UI_VERSION: "0.65.5";
|
|
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.
|
|
13
|
+
export const UI_VERSION = "0.65.5";
|
|
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}
|
package/dist/typedefs.d.ts
CHANGED
|
@@ -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>`
|
|
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>`
|
|
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.
|
|
3
|
+
"version": "0.65.5",
|
|
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.
|
|
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",
|