@sveltia/ui 0.61.0 → 0.62.1
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/button/button.svelte +6 -1
- package/dist/components/resizable-pane/resizable-handle.svelte +12 -3
- package/dist/components/select/select-tags.svelte +8 -1
- package/dist/locales/cs.yaml +113 -0
- package/dist/services/group.svelte.d.ts +2 -1
- package/dist/services/group.svelte.js +183 -50
- package/dist/services/tree.svelte.d.ts +8 -6
- package/dist/services/tree.svelte.js +104 -45
- package/package.json +6 -6
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
} = $props();
|
|
50
50
|
</script>
|
|
51
51
|
|
|
52
|
+
<!--
|
|
53
|
+
The key shortcut handler is attached only when there are shortcuts to bind. A falsy value counts
|
|
54
|
+
as no attachment at all, which saves an effect per button — and both `<Option>` and `<MenuItem>`
|
|
55
|
+
wrap one, so a long list would otherwise pay for an effect per item that has nothing to do.
|
|
56
|
+
-->
|
|
52
57
|
<button
|
|
53
58
|
bind:this={element}
|
|
54
59
|
{...restProps}
|
|
@@ -70,7 +75,7 @@
|
|
|
70
75
|
data-name={name}
|
|
71
76
|
data-label={label}
|
|
72
77
|
data-value={value}
|
|
73
|
-
{@attach activateKeyShortcuts(keyShortcuts)}
|
|
78
|
+
{@attach keyShortcuts && activateKeyShortcuts(keyShortcuts)}
|
|
74
79
|
>
|
|
75
80
|
{@render startIcon?.()}
|
|
76
81
|
{#if variant === 'link'}
|
|
@@ -91,10 +91,19 @@
|
|
|
91
91
|
let keyResizing = $state(false);
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
94
|
+
* The pane group container's size in pixels, used for px→% conversion. Measured once when a drag
|
|
95
|
+
* begins rather than on every pointer move: reading `clientWidth` forces the browser to lay the
|
|
96
|
+
* page out, which is the last thing wanted in the middle of a drag, and the container cannot
|
|
97
|
+
* change size while the pointer holding it is down.
|
|
98
|
+
* @type {number}
|
|
99
|
+
*/
|
|
100
|
+
let containerSize = 0;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Measure the pane group container element's size in pixels.
|
|
95
104
|
* @returns {number} Container size in pixels.
|
|
96
105
|
*/
|
|
97
|
-
const
|
|
106
|
+
const measureContainerSize = () => {
|
|
98
107
|
const container = element?.closest('.resizable-pane-group');
|
|
99
108
|
|
|
100
109
|
if (!container) return 0;
|
|
@@ -116,7 +125,6 @@
|
|
|
116
125
|
|
|
117
126
|
const screenPos = isHorizontal ? screenX : screenY;
|
|
118
127
|
const pixelDelta = screenPos - startScreenPos;
|
|
119
|
-
const containerSize = getContainerSize();
|
|
120
128
|
|
|
121
129
|
if (!containerSize) return;
|
|
122
130
|
|
|
@@ -167,6 +175,7 @@
|
|
|
167
175
|
dragging = true;
|
|
168
176
|
startScreenPos = isHorizontal ? screenX : screenY;
|
|
169
177
|
targetPointerId = pointerId;
|
|
178
|
+
containerSize = measureContainerSize();
|
|
170
179
|
element?.setPointerCapture(pointerId);
|
|
171
180
|
|
|
172
181
|
onResizeStart?.();
|
|
@@ -59,6 +59,13 @@
|
|
|
59
59
|
|
|
60
60
|
/** @type {Map<any, { label: string, value: any, searchValue?: string }>} */
|
|
61
61
|
const optionMap = $derived(new Map(options.map((o) => [o.value, o])));
|
|
62
|
+
/**
|
|
63
|
+
* The selected values as a set, so the option list below can test membership in constant time.
|
|
64
|
+
* Calling `values.includes()` once per option instead makes rendering the list quadratic in the
|
|
65
|
+
* number of options and selected tags.
|
|
66
|
+
* @type {Set<any>}
|
|
67
|
+
*/
|
|
68
|
+
const selectedValues = $derived(new Set(values));
|
|
62
69
|
const prevKey = $derived(isRTL() ? 'ArrowRight' : 'ArrowLeft');
|
|
63
70
|
const nextKey = $derived(isRTL() ? 'ArrowLeft' : 'ArrowRight');
|
|
64
71
|
|
|
@@ -248,7 +255,7 @@
|
|
|
248
255
|
}}
|
|
249
256
|
>
|
|
250
257
|
{#each options as { label, value, searchValue } (value)}
|
|
251
|
-
{#if !
|
|
258
|
+
{#if !selectedValues.has(value)}
|
|
252
259
|
<Option {label} {value} {searchValue} />
|
|
253
260
|
{/if}
|
|
254
261
|
{/each}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Dialog action button labels
|
|
2
|
+
ok: OK
|
|
3
|
+
cancel: Zrušit
|
|
4
|
+
close: Zavřít
|
|
5
|
+
|
|
6
|
+
# Button to clear content, e.g. search input or calendar date
|
|
7
|
+
clear: Vymazat
|
|
8
|
+
|
|
9
|
+
# Text editor dialog buttons for link/image insertion
|
|
10
|
+
insert: Vložit
|
|
11
|
+
update: Aktualizovat
|
|
12
|
+
remove: Odebrat
|
|
13
|
+
|
|
14
|
+
# Combobox dropdown toggle button aria-labels
|
|
15
|
+
collapse: Sbalit
|
|
16
|
+
expand: Rozbalit
|
|
17
|
+
|
|
18
|
+
# Infobar/toast dismiss button label
|
|
19
|
+
dismiss: Zavřít
|
|
20
|
+
|
|
21
|
+
# Calendar component
|
|
22
|
+
calendar:
|
|
23
|
+
# View switcher group aria-labels and navigation button aria-labels
|
|
24
|
+
year: Rok
|
|
25
|
+
previous_decade: Předchozí desetiletí
|
|
26
|
+
next_decade: Další desetiletí
|
|
27
|
+
month: Měsíc
|
|
28
|
+
previous_month: Předchozí měsíc
|
|
29
|
+
next_month: Další měsíc
|
|
30
|
+
# “Today” button label in footer
|
|
31
|
+
today: Dnes
|
|
32
|
+
|
|
33
|
+
# Split button component
|
|
34
|
+
split_button:
|
|
35
|
+
# Wrapper aria-label, e.g. “Save Options” when primary button is “Save”
|
|
36
|
+
x_options: 'Možnosti pro {$name}'
|
|
37
|
+
# Dropdown toggle aria-label
|
|
38
|
+
more_options: Další možnosti
|
|
39
|
+
|
|
40
|
+
# Combobox component
|
|
41
|
+
combobox:
|
|
42
|
+
# Placeholder when no option is selected
|
|
43
|
+
select_an_option: Vyberte možnost…
|
|
44
|
+
# Filter input aria-label
|
|
45
|
+
filter_options: Možnosti filtrování
|
|
46
|
+
# Empty state message when filter has no matches
|
|
47
|
+
no_matching_options: Nebyly nalezeny žádné odpovídající možnosti
|
|
48
|
+
|
|
49
|
+
# Number input component
|
|
50
|
+
number_input:
|
|
51
|
+
# Spin button aria-labels for incrementing/decrementing the number value
|
|
52
|
+
increase: Zvýšit
|
|
53
|
+
decrease: Snížit
|
|
54
|
+
|
|
55
|
+
# Password input component
|
|
56
|
+
password_input:
|
|
57
|
+
# Visibility toggle button aria-labels
|
|
58
|
+
show_password: Zobrazit heslo
|
|
59
|
+
hide_password: Skrýt heslo
|
|
60
|
+
|
|
61
|
+
# Secret input component (API keys, tokens, etc.)
|
|
62
|
+
secret_input:
|
|
63
|
+
# Visibility toggle button aria-labels
|
|
64
|
+
show_secret: Zobrazit tajný klíč
|
|
65
|
+
hide_secret: Skrýt tajný klíč
|
|
66
|
+
|
|
67
|
+
# Select tags component (multi-select)
|
|
68
|
+
select_tags:
|
|
69
|
+
# Listbox aria-label
|
|
70
|
+
selected_options: Vybrané možnosti
|
|
71
|
+
# Remove button aria-label, e.g. “Remove Option 1”
|
|
72
|
+
remove_x: 'Odebrat {$name}'
|
|
73
|
+
|
|
74
|
+
# Text editor component
|
|
75
|
+
text_editor:
|
|
76
|
+
# Toolbar aria-labels
|
|
77
|
+
text_editor: Textový editor
|
|
78
|
+
code_editor: Editor kódu
|
|
79
|
+
# Block style menu button and menu aria-labels
|
|
80
|
+
text_style_options: Možnosti formátování textu
|
|
81
|
+
show_text_style_options: Zobrazit možnosti formátování textu
|
|
82
|
+
# Block style menu items
|
|
83
|
+
paragraph: Odstavec
|
|
84
|
+
heading_1: Nadpis 1
|
|
85
|
+
heading_2: Nadpis 2
|
|
86
|
+
heading_3: Nadpis 3
|
|
87
|
+
heading_4: Nadpis 4
|
|
88
|
+
heading_5: Nadpis 5
|
|
89
|
+
heading_6: Nadpis 6
|
|
90
|
+
bulleted_list: Seznam s odrážkami
|
|
91
|
+
numbered_list: Číslovaný seznam
|
|
92
|
+
blockquote: Citace
|
|
93
|
+
code_block: Blok kódu
|
|
94
|
+
# Inline style button aria-labels
|
|
95
|
+
bold: Tučné
|
|
96
|
+
italic: Kurzíva
|
|
97
|
+
strikethrough: Přeškrtnuté
|
|
98
|
+
code: Kód
|
|
99
|
+
link: Odkaz
|
|
100
|
+
# Link dialog title
|
|
101
|
+
insert_link: Vložit odkaz
|
|
102
|
+
update_link: Aktualizovat odkaz
|
|
103
|
+
# Link dialog input field labels
|
|
104
|
+
text: Text
|
|
105
|
+
url: URL
|
|
106
|
+
# Markdown mode toggle button aria-label
|
|
107
|
+
edit_in_markdown: Upravit v Markdownu
|
|
108
|
+
# Error message when rich text conversion fails
|
|
109
|
+
converter_error: Nelze aktivovat režim formátovaného textu. Použijte prosím místo toho editor prostého textu.
|
|
110
|
+
# Language selector aria-label
|
|
111
|
+
language: Jazyk
|
|
112
|
+
# Plain text mode option in language selector
|
|
113
|
+
plain_text: Prostý text
|
|
@@ -8,7 +8,6 @@ export class Group {
|
|
|
8
8
|
* @param {HTMLElement} parent Parent element.
|
|
9
9
|
* @param {object} [options] Options.
|
|
10
10
|
* @param {boolean} [options.clickToSelect] Whether to select an item by clicking on it.
|
|
11
|
-
* @todo Check for added elements probably with `MutationObserver`.
|
|
12
11
|
*/
|
|
13
12
|
constructor(parent: HTMLElement, { clickToSelect }?: {
|
|
14
13
|
clickToSelect?: boolean | undefined;
|
|
@@ -46,6 +45,7 @@ export class Group {
|
|
|
46
45
|
* @type {'selected' | 'first'}
|
|
47
46
|
*/
|
|
48
47
|
rovingTabStop: "selected" | "first";
|
|
48
|
+
observer: MutationObserver;
|
|
49
49
|
/**
|
|
50
50
|
* Activate the members.
|
|
51
51
|
*/
|
|
@@ -152,6 +152,7 @@ export class Group {
|
|
|
152
152
|
onUpdate({ searchTerms }: {
|
|
153
153
|
searchTerms: string;
|
|
154
154
|
}): void;
|
|
155
|
+
#private;
|
|
155
156
|
}
|
|
156
157
|
export function activateGroup(paramsOrGetter?: object | (() => object) | undefined): Attachment;
|
|
157
158
|
import type { Attachment } from 'svelte/attachments';
|
|
@@ -105,19 +105,45 @@ const config = {
|
|
|
105
105
|
const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, summary, [tabindex]';
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
*
|
|
109
|
-
* sequence, are not accounted for; they’re discouraged and absent from
|
|
108
|
+
* Move focus to the tab stop next to the given element in document order. Positive `tabindex`
|
|
109
|
+
* values, which reorder the sequence, are not accounted for; they’re discouraged and absent from
|
|
110
|
+
* this library.
|
|
111
|
+
*
|
|
112
|
+
* The candidates are narrowed by attribute alone before any of them is measured, and the measuring
|
|
113
|
+
* — `getClientRects()`, which forces the browser to lay the page out — then walks outward from the
|
|
114
|
+
* element and stops at the first candidate that is actually rendered. Measuring every focusable
|
|
115
|
+
* element in the document up front is what a menu sitting on a large page cannot afford.
|
|
110
116
|
* @internal
|
|
111
|
-
* @
|
|
117
|
+
* @param {HTMLElement} element Element to start from.
|
|
118
|
+
* @param {boolean} backwards Whether to move to the previous tab stop rather than the next.
|
|
112
119
|
*/
|
|
113
|
-
const
|
|
114
|
-
/** @type {HTMLElement[]} */ ([
|
|
115
|
-
(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
const focusAdjacentTabStop = (element, backwards) => {
|
|
121
|
+
const candidates = /** @type {HTMLElement[]} */ ([
|
|
122
|
+
...document.querySelectorAll(FOCUSABLE_SELECTOR),
|
|
123
|
+
]).filter(
|
|
124
|
+
(candidate) =>
|
|
125
|
+
candidate === element ||
|
|
126
|
+
(candidate.tabIndex >= 0 &&
|
|
127
|
+
!candidate.matches(':disabled, [aria-disabled="true"], [hidden], [inert], [inert] *')),
|
|
119
128
|
);
|
|
120
129
|
|
|
130
|
+
const index = candidates.indexOf(element);
|
|
131
|
+
|
|
132
|
+
if (index === -1) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const step = backwards ? -1 : 1;
|
|
137
|
+
|
|
138
|
+
for (let i = index + step; i >= 0 && i < candidates.length; i += step) {
|
|
139
|
+
if (candidates[i].getClientRects().length) {
|
|
140
|
+
candidates[i].focus();
|
|
141
|
+
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
121
147
|
/**
|
|
122
148
|
* Find the element that opens the menu the given element belongs to. A menu lives outside its
|
|
123
149
|
* opener in the DOM tree, so the link runs the other way: the opener points at the menu’s container
|
|
@@ -151,12 +177,60 @@ const getMenuOpener = (element) => {
|
|
|
151
177
|
* Implement keyboard and mouse interactions for a grouping composite widget.
|
|
152
178
|
*/
|
|
153
179
|
export class Group {
|
|
180
|
+
/**
|
|
181
|
+
* Memoized member lists, discarded whenever the widget’s subtree changes. See {@link #members}.
|
|
182
|
+
* @type {{ all: HTMLElement[], active: HTMLElement[] } | undefined}
|
|
183
|
+
*/
|
|
184
|
+
#memberCache = undefined;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Normalized search value of each member, kept alongside the raw value it was derived from so it
|
|
188
|
+
* can be reused until the member’s label actually changes. Filtering runs over every member on
|
|
189
|
+
* every keystroke, and `normalize()` is not free — it decomposes the string and strips the
|
|
190
|
+
* diacritics.
|
|
191
|
+
* @type {WeakMap<HTMLElement, { raw: string, normalized: string }>}
|
|
192
|
+
*/
|
|
193
|
+
#searchValueCache = new WeakMap();
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Hidden state each member was last told about, so the filter can skip the members whose state
|
|
197
|
+
* hasn’t moved. The DOM is deliberately not consulted for this: the listener updates a component,
|
|
198
|
+
* which renders asynchronously, so two keystrokes within a frame would read a stale attribute.
|
|
199
|
+
* @type {WeakMap<HTMLElement, boolean>}
|
|
200
|
+
*/
|
|
201
|
+
#hiddenState = new WeakMap();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the normalized value a member is searched by, computing it only when the underlying raw
|
|
205
|
+
* value has changed since the last call.
|
|
206
|
+
* @param {HTMLElement} member Member element.
|
|
207
|
+
* @returns {string} Normalized search value.
|
|
208
|
+
*/
|
|
209
|
+
#getNormalizedSearchValue(member) {
|
|
210
|
+
const raw =
|
|
211
|
+
member.dataset.searchValue ??
|
|
212
|
+
member.dataset.label ??
|
|
213
|
+
member.querySelector('.label')?.textContent ??
|
|
214
|
+
/** @type {string} */ (member.textContent);
|
|
215
|
+
|
|
216
|
+
const cached = this.#searchValueCache.get(member);
|
|
217
|
+
|
|
218
|
+
if (cached?.raw === raw) {
|
|
219
|
+
return cached.normalized;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const normalized = normalize(raw);
|
|
223
|
+
|
|
224
|
+
this.#searchValueCache.set(member, { raw, normalized });
|
|
225
|
+
|
|
226
|
+
return normalized;
|
|
227
|
+
}
|
|
228
|
+
|
|
154
229
|
/**
|
|
155
230
|
* Initialize a new `Group` instance.
|
|
156
231
|
* @param {HTMLElement} parent Parent element.
|
|
157
232
|
* @param {object} [options] Options.
|
|
158
233
|
* @param {boolean} [options.clickToSelect] Whether to select an item by clicking on it.
|
|
159
|
-
* @todo Check for added elements probably with `MutationObserver`.
|
|
160
234
|
*/
|
|
161
235
|
constructor(parent, { clickToSelect = true } = {}) {
|
|
162
236
|
parent.dispatchEvent(new CustomEvent('Initializing'));
|
|
@@ -218,6 +292,20 @@ export class Group {
|
|
|
218
292
|
|
|
219
293
|
this.parent.tabIndex = focusChild ? -1 : 0;
|
|
220
294
|
|
|
295
|
+
// The members can be added, removed, disabled or hidden at any time, which is what invalidates
|
|
296
|
+
// the cached lists. Only the attributes that decide membership are watched, so the group’s own
|
|
297
|
+
// writes — the selected state and the roving `tabindex` — don’t needlessly discard the cache.
|
|
298
|
+
this.observer = new globalThis.MutationObserver(() => {
|
|
299
|
+
this.#memberCache = undefined;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.observer.observe(parent, {
|
|
303
|
+
childList: true,
|
|
304
|
+
subtree: true,
|
|
305
|
+
attributes: true,
|
|
306
|
+
attributeFilter: ['aria-disabled', 'aria-hidden'],
|
|
307
|
+
});
|
|
308
|
+
|
|
221
309
|
// Wait a bit before the relevant components, including the `aria-controls` target are mounted
|
|
222
310
|
(async () => {
|
|
223
311
|
await sleep(100);
|
|
@@ -292,8 +380,9 @@ export class Group {
|
|
|
292
380
|
|
|
293
381
|
const tabStop =
|
|
294
382
|
this.rovingTabStop === 'selected'
|
|
295
|
-
? (activeMembers.find(
|
|
296
|
-
|
|
383
|
+
? (activeMembers.find(
|
|
384
|
+
(element) => element.getAttribute(this.childSelectedAttr) === 'true',
|
|
385
|
+
) ?? activeMembers[0])
|
|
297
386
|
: activeMembers[0];
|
|
298
387
|
|
|
299
388
|
allMembers.forEach((element) => {
|
|
@@ -309,12 +398,42 @@ export class Group {
|
|
|
309
398
|
return this.childRoles.map((role) => `[role="${role}"]`).join(',');
|
|
310
399
|
}
|
|
311
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The member lists, recomputed only once the widget’s subtree has actually changed. A single
|
|
403
|
+
* arrow key reaches these several times over, and a composite widget can hold thousands of
|
|
404
|
+
* members, so the `querySelectorAll` and the `matches()` per member that back them are far too
|
|
405
|
+
* expensive to repeat on every read.
|
|
406
|
+
*
|
|
407
|
+
* The observer’s pending records are drained here rather than left to its callback, which runs a
|
|
408
|
+
* microtask later — too late for a read that happens synchronously after a mutation, as when a
|
|
409
|
+
* key is dispatched right after the members are swapped out.
|
|
410
|
+
* @type {{ all: HTMLElement[], active: HTMLElement[] }}
|
|
411
|
+
*/
|
|
412
|
+
get #members() {
|
|
413
|
+
if (this.observer.takeRecords().length) {
|
|
414
|
+
this.#memberCache = undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!this.#memberCache) {
|
|
418
|
+
const all = /** @type {HTMLElement[]} */ ([...this.parent.querySelectorAll(this.selector)]);
|
|
419
|
+
|
|
420
|
+
this.#memberCache = {
|
|
421
|
+
all,
|
|
422
|
+
active: all.filter(
|
|
423
|
+
(element) => !element.matches('[aria-disabled="true"], [aria-hidden="true"]'),
|
|
424
|
+
),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return this.#memberCache;
|
|
429
|
+
}
|
|
430
|
+
|
|
312
431
|
/**
|
|
313
432
|
* List of all the members.
|
|
314
433
|
* @type {HTMLElement[]}
|
|
315
434
|
*/
|
|
316
435
|
get allMembers() {
|
|
317
|
-
return
|
|
436
|
+
return this.#members.all;
|
|
318
437
|
}
|
|
319
438
|
|
|
320
439
|
/**
|
|
@@ -322,9 +441,7 @@ export class Group {
|
|
|
322
441
|
* @type {HTMLElement[]}
|
|
323
442
|
*/
|
|
324
443
|
get activeMembers() {
|
|
325
|
-
return this.
|
|
326
|
-
(element) => !element.matches('[aria-disabled="true"], [aria-hidden="true"]'),
|
|
327
|
-
);
|
|
444
|
+
return this.#members.active;
|
|
328
445
|
}
|
|
329
446
|
|
|
330
447
|
/**
|
|
@@ -392,13 +509,7 @@ export class Group {
|
|
|
392
509
|
// Somewhere to stand while the menu is torn down, and the fallback if there’s nothing beyond
|
|
393
510
|
opener.focus();
|
|
394
511
|
await sleep(50);
|
|
395
|
-
|
|
396
|
-
const tabStops = getTabStops();
|
|
397
|
-
const index = tabStops.indexOf(opener);
|
|
398
|
-
|
|
399
|
-
if (index > -1) {
|
|
400
|
-
tabStops[index + (backwards ? -1 : 1)]?.focus();
|
|
401
|
-
}
|
|
512
|
+
focusAdjacentTabStop(opener, backwards);
|
|
402
513
|
}
|
|
403
514
|
|
|
404
515
|
/**
|
|
@@ -455,8 +566,8 @@ export class Group {
|
|
|
455
566
|
* @type {HTMLElement | undefined}
|
|
456
567
|
*/
|
|
457
568
|
get selected() {
|
|
458
|
-
return this.activeMembers.find(
|
|
459
|
-
element.
|
|
569
|
+
return this.activeMembers.find(
|
|
570
|
+
(element) => element.getAttribute(this.childSelectedAttr) === 'true',
|
|
460
571
|
);
|
|
461
572
|
}
|
|
462
573
|
|
|
@@ -503,14 +614,28 @@ export class Group {
|
|
|
503
614
|
const selectByKeydown =
|
|
504
615
|
event.type === 'keydown' && /** @type {KeyboardEvent} */ (event).key === ' ';
|
|
505
616
|
|
|
617
|
+
/**
|
|
618
|
+
* Members that took part in this selection, and whose roving `tabindex` therefore has to be
|
|
619
|
+
* updated. The ones skipped below belong to another menu group and are left alone.
|
|
620
|
+
* @type {HTMLElement[]}
|
|
621
|
+
*/
|
|
622
|
+
const affected = [];
|
|
623
|
+
/**
|
|
624
|
+
* Whether the target itself took part, meaning it’s the one to receive focus.
|
|
625
|
+
* @type {boolean}
|
|
626
|
+
*/
|
|
627
|
+
let targetAffected = false;
|
|
628
|
+
|
|
506
629
|
this.activeMembers.forEach((element) => {
|
|
507
|
-
|
|
508
|
-
|
|
630
|
+
// Reading the role once and comparing it is markedly cheaper than putting every member
|
|
631
|
+
// through the selector engine three times over
|
|
632
|
+
const role = element.getAttribute('role');
|
|
633
|
+
const isMenuItemCheckbox = role === 'menuitemcheckbox';
|
|
634
|
+
const isMenuItemRadio = role === 'menuitemradio';
|
|
509
635
|
|
|
510
636
|
if (
|
|
511
637
|
(isMenuItemCheckbox || isMenuItemRadio) &&
|
|
512
|
-
(element.
|
|
513
|
-
element.closest(this.parentGroupSelector) !== targetParent)
|
|
638
|
+
(role !== targetRole || element.closest(this.parentGroupSelector) !== targetParent)
|
|
514
639
|
) {
|
|
515
640
|
return;
|
|
516
641
|
}
|
|
@@ -518,10 +643,13 @@ export class Group {
|
|
|
518
643
|
const multiSelect = isMenuItemCheckbox || this.multi;
|
|
519
644
|
const singleSelect = isMenuItemRadio || !multiSelect;
|
|
520
645
|
const isTarget = element === newTarget;
|
|
521
|
-
const isSelected = element.
|
|
646
|
+
const isSelected = element.getAttribute(this.childSelectedAttr) === 'true';
|
|
522
647
|
const controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
|
|
523
648
|
const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
|
|
524
649
|
|
|
650
|
+
affected.push(element);
|
|
651
|
+
targetAffected ||= isTarget;
|
|
652
|
+
|
|
525
653
|
if (multiSelect && isTarget && (selectByClick || selectByKeydown)) {
|
|
526
654
|
element.setAttribute(this.childSelectedAttr, String(!isSelected));
|
|
527
655
|
element.dispatchEvent(
|
|
@@ -544,7 +672,7 @@ export class Group {
|
|
|
544
672
|
);
|
|
545
673
|
|
|
546
674
|
if (isTarget) {
|
|
547
|
-
if (event.type === 'keydown' &&
|
|
675
|
+
if (event.type === 'keydown' && role === 'radio') {
|
|
548
676
|
element.click();
|
|
549
677
|
}
|
|
550
678
|
|
|
@@ -552,17 +680,7 @@ export class Group {
|
|
|
552
680
|
}
|
|
553
681
|
}
|
|
554
682
|
|
|
555
|
-
if (this.focusChild) {
|
|
556
|
-
// Wait a bit before the element is rerendered
|
|
557
|
-
globalThis.requestAnimationFrame(() => {
|
|
558
|
-
element.tabIndex = isTarget ? 0 : -1;
|
|
559
|
-
|
|
560
|
-
if (isTarget) {
|
|
561
|
-
element.focus();
|
|
562
|
-
element.dispatchEvent(new CustomEvent('Focus'));
|
|
563
|
-
}
|
|
564
|
-
});
|
|
565
|
-
} else {
|
|
683
|
+
if (!this.focusChild) {
|
|
566
684
|
element.classList.toggle('focused', isTarget);
|
|
567
685
|
|
|
568
686
|
if (isTarget) {
|
|
@@ -602,6 +720,21 @@ export class Group {
|
|
|
602
720
|
}
|
|
603
721
|
});
|
|
604
722
|
|
|
723
|
+
if (this.focusChild) {
|
|
724
|
+
// Wait a bit before the elements are rerendered. A single frame serves the whole group;
|
|
725
|
+
// scheduling a callback per member would queue thousands of them on a large widget.
|
|
726
|
+
globalThis.requestAnimationFrame(() => {
|
|
727
|
+
affected.forEach((element) => {
|
|
728
|
+
element.tabIndex = element === newTarget ? 0 : -1;
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
if (targetAffected) {
|
|
732
|
+
newTarget.focus();
|
|
733
|
+
newTarget.dispatchEvent(new CustomEvent('Focus'));
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
605
738
|
this.parent.dispatchEvent(
|
|
606
739
|
new CustomEvent('Change', { detail: getSelectedItemDetail(newTarget) }),
|
|
607
740
|
);
|
|
@@ -796,6 +929,7 @@ export class Group {
|
|
|
796
929
|
* Clean up event listeners.
|
|
797
930
|
*/
|
|
798
931
|
destroy() {
|
|
932
|
+
this.observer.disconnect();
|
|
799
933
|
this.parent.removeEventListener('click', this._onClick);
|
|
800
934
|
this.parent.removeEventListener('keydown', this._onKeyDown);
|
|
801
935
|
}
|
|
@@ -811,16 +945,15 @@ export class Group {
|
|
|
811
945
|
|
|
812
946
|
const matched = allMembers
|
|
813
947
|
.map((member) => {
|
|
814
|
-
const searchValue =
|
|
815
|
-
member.dataset.searchValue ??
|
|
816
|
-
member.dataset.label ??
|
|
817
|
-
member.querySelector('.label')?.textContent ??
|
|
818
|
-
/** @type {string} */ (member.textContent),
|
|
819
|
-
);
|
|
820
|
-
|
|
948
|
+
const searchValue = this.#getNormalizedSearchValue(member);
|
|
821
949
|
const hidden = !_terms.every((term) => searchValue.includes(term));
|
|
822
950
|
|
|
823
|
-
|
|
951
|
+
// Only report an actual change. Every keystroke runs this over every member, and the
|
|
952
|
+
// listener on the other end drives a component update
|
|
953
|
+
if (this.#hiddenState.get(member) !== hidden) {
|
|
954
|
+
this.#hiddenState.set(member, hidden);
|
|
955
|
+
member.dispatchEvent(new CustomEvent('Toggle', { detail: { hidden } }));
|
|
956
|
+
}
|
|
824
957
|
|
|
825
958
|
return hidden;
|
|
826
959
|
})
|
|
@@ -137,12 +137,6 @@ export class Tree {
|
|
|
137
137
|
* @returns {boolean} Result.
|
|
138
138
|
*/
|
|
139
139
|
isExpanded(item: HTMLElement): boolean;
|
|
140
|
-
/**
|
|
141
|
-
* Whether any of the ancestors of the given item is collapsed, meaning the item is not displayed.
|
|
142
|
-
* @param {HTMLElement} item Item.
|
|
143
|
-
* @returns {boolean} Result.
|
|
144
|
-
*/
|
|
145
|
-
hasCollapsedAncestor(item: HTMLElement): boolean;
|
|
146
140
|
/**
|
|
147
141
|
* Get the text label of the given item, which is used for the type-ahead search.
|
|
148
142
|
* @param {HTMLElement} item Item.
|
|
@@ -159,6 +153,14 @@ export class Tree {
|
|
|
159
153
|
* @param {HTMLElement} element Element to be scrolled into view.
|
|
160
154
|
*/
|
|
161
155
|
scrollIntoView(element: HTMLElement): void;
|
|
156
|
+
/**
|
|
157
|
+
* Put exactly one item in the tab order. Only the items actually in it are touched: writing a
|
|
158
|
+
* `tabindex` to every item costs a pass over the whole widget on each arrow key, and all but one
|
|
159
|
+
* of those writes set the value the item already had.
|
|
160
|
+
* @param {HTMLElement} [item] Item to become the tab stop. When omitted, no item is left in the
|
|
161
|
+
* tab order, which is the case for a tree with nothing to focus.
|
|
162
|
+
*/
|
|
163
|
+
setTabStop(item?: HTMLElement | undefined): void;
|
|
162
164
|
/**
|
|
163
165
|
* Move focus to the given item.
|
|
164
166
|
* @param {HTMLElement} item Item to be focused.
|
|
@@ -16,10 +16,6 @@ const TYPE_AHEAD_TIMEOUT = 500;
|
|
|
16
16
|
* CSS selector to retrieve the tree items.
|
|
17
17
|
*/
|
|
18
18
|
const ITEM_SELECTOR = '[role="treeitem"]';
|
|
19
|
-
/**
|
|
20
|
-
* CSS selector to retrieve the tree item containers, including the widget root.
|
|
21
|
-
*/
|
|
22
|
-
const GROUP_SELECTOR = '[role="group"], [role="tree"]';
|
|
23
19
|
|
|
24
20
|
/**
|
|
25
21
|
* Implement keyboard and mouse interactions for the `tree` composite widget, following the ARIA
|
|
@@ -164,9 +160,48 @@ export class Tree {
|
|
|
164
160
|
* @type {HTMLElement[]}
|
|
165
161
|
*/
|
|
166
162
|
get visibleItems() {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
163
|
+
/** @type {HTMLElement[]} */
|
|
164
|
+
const items = [];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Collect the items below the given node in document order, descending only into the parents
|
|
168
|
+
* that are actually expanded. Pruning the collapsed subtrees outright is what keeps this
|
|
169
|
+
* proportional to the number of displayed items; testing each item in the tree for a collapsed
|
|
170
|
+
* ancestor instead costs a walk back up per item.
|
|
171
|
+
* @param {Element} node Node whose children to scan.
|
|
172
|
+
*/
|
|
173
|
+
const collect = (node) => {
|
|
174
|
+
[...node.children].forEach((child) => {
|
|
175
|
+
if (child.getAttribute('role') !== 'treeitem') {
|
|
176
|
+
// Anything else is a wrapper to see through — including a group that no item owns, whose
|
|
177
|
+
// items sit at the level of the group itself
|
|
178
|
+
collect(child);
|
|
179
|
+
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const item = /** @type {HTMLElement} */ (child);
|
|
184
|
+
|
|
185
|
+
if (!item.matches('[hidden], [aria-hidden="true"]')) {
|
|
186
|
+
items.push(item);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A collapsed parent hides everything below it, so there is nothing to descend into
|
|
190
|
+
if (this.isParent(item) && !this.isExpanded(item)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const group = this.getGroup(item);
|
|
195
|
+
|
|
196
|
+
if (group) {
|
|
197
|
+
collect(group);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
collect(this.parent);
|
|
203
|
+
|
|
204
|
+
return items;
|
|
170
205
|
}
|
|
171
206
|
|
|
172
207
|
/**
|
|
@@ -182,7 +217,9 @@ export class Tree {
|
|
|
182
217
|
* @type {HTMLElement[]}
|
|
183
218
|
*/
|
|
184
219
|
get selectedItems() {
|
|
185
|
-
return
|
|
220
|
+
return /** @type {HTMLElement[]} */ ([
|
|
221
|
+
...this.parent.querySelectorAll(`${ITEM_SELECTOR}[aria-selected="true"]`),
|
|
222
|
+
]);
|
|
186
223
|
}
|
|
187
224
|
|
|
188
225
|
/**
|
|
@@ -221,9 +258,31 @@ export class Tree {
|
|
|
221
258
|
* @returns {HTMLElement[]} Child items.
|
|
222
259
|
*/
|
|
223
260
|
getItemsInGroup(group) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
/** @type {HTMLElement[]} */
|
|
262
|
+
const items = [];
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Scan the children of the given node, descending through plain wrapper elements but never into
|
|
266
|
+
* an item or a nested group — whatever lies below those belongs to the item that owns them.
|
|
267
|
+
* Querying the whole subtree and then discarding the deeper items with a `closest()` call each
|
|
268
|
+
* costs `update()`, which runs this once per group, quadratic time overall.
|
|
269
|
+
* @param {Element} node Node whose children to scan.
|
|
270
|
+
*/
|
|
271
|
+
const walk = (node) => {
|
|
272
|
+
[...node.children].forEach((child) => {
|
|
273
|
+
const role = child.getAttribute('role');
|
|
274
|
+
|
|
275
|
+
if (role === 'treeitem') {
|
|
276
|
+
items.push(/** @type {HTMLElement} */ (child));
|
|
277
|
+
} else if (role !== 'group' && role !== 'tree') {
|
|
278
|
+
walk(child);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
walk(group);
|
|
284
|
+
|
|
285
|
+
return items;
|
|
227
286
|
}
|
|
228
287
|
|
|
229
288
|
/**
|
|
@@ -255,25 +314,6 @@ export class Tree {
|
|
|
255
314
|
return item.getAttribute('aria-expanded') === 'true';
|
|
256
315
|
}
|
|
257
316
|
|
|
258
|
-
/**
|
|
259
|
-
* Whether any of the ancestors of the given item is collapsed, meaning the item is not displayed.
|
|
260
|
-
* @param {HTMLElement} item Item.
|
|
261
|
-
* @returns {boolean} Result.
|
|
262
|
-
*/
|
|
263
|
-
hasCollapsedAncestor(item) {
|
|
264
|
-
let ancestor = this.getParentItem(item);
|
|
265
|
-
|
|
266
|
-
while (ancestor) {
|
|
267
|
-
if (!this.isExpanded(ancestor)) {
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
ancestor = this.getParentItem(ancestor);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
317
|
/**
|
|
278
318
|
* Get the text label of the given item, which is used for the type-ahead search.
|
|
279
319
|
* @param {HTMLElement} item Item.
|
|
@@ -316,12 +356,10 @@ export class Tree {
|
|
|
316
356
|
const current =
|
|
317
357
|
activeItems.find((item) => item === document.activeElement) ??
|
|
318
358
|
activeItems.find((item) => item.tabIndex === 0) ??
|
|
319
|
-
activeItems.find((item) => item.
|
|
359
|
+
activeItems.find((item) => item.getAttribute('aria-selected') === 'true') ??
|
|
320
360
|
activeItems[0];
|
|
321
361
|
|
|
322
|
-
|
|
323
|
-
item.tabIndex = item === current ? 0 : -1;
|
|
324
|
-
});
|
|
362
|
+
this.setTabStop(current);
|
|
325
363
|
}
|
|
326
364
|
|
|
327
365
|
/**
|
|
@@ -336,6 +374,27 @@ export class Tree {
|
|
|
336
374
|
}
|
|
337
375
|
}
|
|
338
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Put exactly one item in the tab order. Only the items actually in it are touched: writing a
|
|
379
|
+
* `tabindex` to every item costs a pass over the whole widget on each arrow key, and all but one
|
|
380
|
+
* of those writes set the value the item already had.
|
|
381
|
+
* @param {HTMLElement} [item] Item to become the tab stop. When omitted, no item is left in the
|
|
382
|
+
* tab order, which is the case for a tree with nothing to focus.
|
|
383
|
+
*/
|
|
384
|
+
setTabStop(item) {
|
|
385
|
+
this.parent
|
|
386
|
+
.querySelectorAll(`${ITEM_SELECTOR}[tabindex]:not([tabindex="-1"])`)
|
|
387
|
+
.forEach((element) => {
|
|
388
|
+
if (element !== item) {
|
|
389
|
+
/** @type {HTMLElement} */ (element).tabIndex = -1;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
if (item) {
|
|
394
|
+
item.tabIndex = 0;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
339
398
|
/**
|
|
340
399
|
* Move focus to the given item.
|
|
341
400
|
* @param {HTMLElement} item Item to be focused.
|
|
@@ -344,10 +403,7 @@ export class Tree {
|
|
|
344
403
|
* `selectionFollowsFocus` option.
|
|
345
404
|
*/
|
|
346
405
|
focusItem(item, { select = this.selectionFollowsFocus } = {}) {
|
|
347
|
-
this.
|
|
348
|
-
element.tabIndex = element === item ? 0 : -1;
|
|
349
|
-
});
|
|
350
|
-
|
|
406
|
+
this.setTabStop(item);
|
|
351
407
|
item.focus();
|
|
352
408
|
item.dispatchEvent(new CustomEvent('Focus'));
|
|
353
409
|
this.scrollIntoView(item);
|
|
@@ -363,7 +419,7 @@ export class Tree {
|
|
|
363
419
|
* @param {boolean} selected Whether to select the item.
|
|
364
420
|
*/
|
|
365
421
|
setSelected(item, selected) {
|
|
366
|
-
if (item.
|
|
422
|
+
if ((item.getAttribute('aria-selected') === 'true') === selected) {
|
|
367
423
|
return;
|
|
368
424
|
}
|
|
369
425
|
|
|
@@ -402,13 +458,18 @@ export class Tree {
|
|
|
402
458
|
);
|
|
403
459
|
});
|
|
404
460
|
} else if (multi && additive) {
|
|
405
|
-
this.setSelected(item,
|
|
461
|
+
this.setSelected(item, item.getAttribute('aria-selected') !== 'true');
|
|
406
462
|
this.anchor = item;
|
|
407
463
|
} else {
|
|
408
|
-
|
|
409
|
-
|
|
464
|
+
// Only the items that are actually selected need clearing; running every item in the tree
|
|
465
|
+
// through `setSelected()` would leave all but one of them untouched anyway
|
|
466
|
+
this.selectedItems.forEach((element) => {
|
|
467
|
+
if (element !== item) {
|
|
468
|
+
this.setSelected(element, false);
|
|
469
|
+
}
|
|
410
470
|
});
|
|
411
471
|
|
|
472
|
+
this.setSelected(item, true);
|
|
412
473
|
this.anchor = item;
|
|
413
474
|
}
|
|
414
475
|
|
|
@@ -517,9 +578,7 @@ export class Tree {
|
|
|
517
578
|
return;
|
|
518
579
|
}
|
|
519
580
|
|
|
520
|
-
this.
|
|
521
|
-
element.tabIndex = element === item ? 0 : -1;
|
|
522
|
-
});
|
|
581
|
+
this.setTabStop(item);
|
|
523
582
|
}
|
|
524
583
|
|
|
525
584
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltia/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.1",
|
|
4
4
|
"description": "A collection of Svelte components and utilities for building user interfaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"@lexical/selection": "^0.49.0",
|
|
47
47
|
"@lexical/table": "^0.49.0",
|
|
48
48
|
"@lexical/utils": "^0.49.0",
|
|
49
|
-
"@sveltia/i18n": "^1.
|
|
50
|
-
"@sveltia/utils": "^0.12.
|
|
49
|
+
"@sveltia/i18n": "^1.2.2",
|
|
50
|
+
"@sveltia/utils": "^0.12.1",
|
|
51
51
|
"lexical": "^0.49.0",
|
|
52
52
|
"prismjs": "^1.30.0"
|
|
53
53
|
},
|
|
@@ -63,10 +63,10 @@
|
|
|
63
63
|
"eslint-config-airbnb-extended": "^3.2.0",
|
|
64
64
|
"eslint-config-prettier": "^10.1.8",
|
|
65
65
|
"eslint-plugin-import": "^2.32.0",
|
|
66
|
-
"eslint-plugin-jsdoc": "^64.0
|
|
67
|
-
"eslint-plugin-package-json": "^1.7.
|
|
66
|
+
"eslint-plugin-jsdoc": "^64.1.0",
|
|
67
|
+
"eslint-plugin-package-json": "^1.7.1",
|
|
68
68
|
"eslint-plugin-svelte": "^3.22.0",
|
|
69
|
-
"globals": "^17.
|
|
69
|
+
"globals": "^17.10.0",
|
|
70
70
|
"happy-dom": "^20.11.2",
|
|
71
71
|
"oxlint": "^1.78.0",
|
|
72
72
|
"postcss": "^8.5.26",
|