@sveltia/ui 0.62.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.
@@ -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
- * Get the pane group container element's size in pixels for px→% conversion.
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 getContainerSize = () => {
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 !values.includes(value)}
258
+ {#if !selectedValues.has(value)}
252
259
  <Option {label} {value} {searchValue} />
253
260
  {/if}
254
261
  {/each}
@@ -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
- * List the document’s tab stops in document order. Positive `tabindex` values, which reorder the
109
- * sequence, are not accounted for; they’re discouraged and absent from this library.
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
- * @returns {HTMLElement[]} Elements that can be reached with Tab.
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 getTabStops = () =>
114
- /** @type {HTMLElement[]} */ ([...document.querySelectorAll(FOCUSABLE_SELECTOR)]).filter(
115
- (element) =>
116
- element.tabIndex >= 0 &&
117
- !element.matches(':disabled, [aria-disabled="true"], [hidden], [inert], [inert] *') &&
118
- !!element.getClientRects().length,
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((element) => element.matches(`[${this.childSelectedAttr}="true"]`)) ??
296
- activeMembers[0])
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 /** @type {HTMLElement[]} */ ([...this.parent.querySelectorAll(this.selector)]);
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.allMembers.filter(
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((element) =>
459
- element.matches(`[${this.childSelectedAttr}="true"]`),
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
- const isMenuItemCheckbox = element.matches('[role="menuitemcheckbox"]');
508
- const isMenuItemRadio = element.matches('[role="menuitemradio"]');
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.getAttribute('role') !== targetRole ||
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.matches(`[${this.childSelectedAttr}="true"]`);
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' && element.matches('[role="radio"]')) {
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 = normalize(
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
- member.dispatchEvent(new CustomEvent('Toggle', { detail: { hidden } }));
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
- return this.allItems.filter(
168
- (item) => !item.matches('[hidden], [aria-hidden="true"]') && !this.hasCollapsedAncestor(item),
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 this.allItems.filter((item) => item.matches('[aria-selected="true"]'));
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
- return /** @type {HTMLElement[]} */ ([...group.querySelectorAll(ITEM_SELECTOR)]).filter(
225
- (item) => item.parentElement?.closest(GROUP_SELECTOR) === group,
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.matches('[aria-selected="true"]')) ??
359
+ activeItems.find((item) => item.getAttribute('aria-selected') === 'true') ??
320
360
  activeItems[0];
321
361
 
322
- allItems.forEach((item) => {
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.allItems.forEach((element) => {
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.matches('[aria-selected="true"]') === selected) {
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, !item.matches('[aria-selected="true"]'));
461
+ this.setSelected(item, item.getAttribute('aria-selected') !== 'true');
406
462
  this.anchor = item;
407
463
  } else {
408
- this.allItems.forEach((element) => {
409
- this.setSelected(element, element === item);
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.allItems.forEach((element) => {
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.62.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.1.3",
50
- "@sveltia/utils": "^0.12.0",
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
  },
@@ -64,9 +64,9 @@
64
64
  "eslint-config-prettier": "^10.1.8",
65
65
  "eslint-plugin-import": "^2.32.0",
66
66
  "eslint-plugin-jsdoc": "^64.1.0",
67
- "eslint-plugin-package-json": "^1.7.0",
67
+ "eslint-plugin-package-json": "^1.7.1",
68
68
  "eslint-plugin-svelte": "^3.22.0",
69
- "globals": "^17.9.0",
69
+ "globals": "^17.10.0",
70
70
  "happy-dom": "^20.11.2",
71
71
  "oxlint": "^1.78.0",
72
72
  "postcss": "^8.5.26",