@sveltia/ui 0.62.0 → 0.63.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.
Files changed (33) hide show
  1. package/dist/components/button/button.svelte +6 -1
  2. package/dist/components/resizable-pane/resizable-handle.svelte +12 -3
  3. package/dist/components/select/select-tags.svelte +8 -1
  4. package/dist/components/text-editor/constants.d.ts +0 -1
  5. package/dist/components/text-editor/constants.js +1 -36
  6. package/dist/components/text-editor/core.js +24 -31
  7. package/dist/components/text-editor/lexical-root.svelte +0 -68
  8. package/dist/components/text-editor/shiki/cache.d.ts +3 -0
  9. package/dist/components/text-editor/shiki/cache.js +121 -0
  10. package/dist/components/text-editor/shiki/engine-entry.d.ts +2 -0
  11. package/dist/components/text-editor/shiki/engine-entry.js +20 -0
  12. package/dist/components/text-editor/shiki/facade.d.ts +16 -0
  13. package/dist/components/text-editor/shiki/facade.js +452 -0
  14. package/dist/components/text-editor/shiki/generated.d.ts +28 -0
  15. package/dist/components/text-editor/shiki/generated.js +25 -0
  16. package/dist/components/text-editor/shiki/highlighter.d.ts +11 -0
  17. package/dist/components/text-editor/shiki/highlighter.js +477 -0
  18. package/dist/components/text-editor/shiki/loader.d.ts +6 -0
  19. package/dist/components/text-editor/shiki/loader.js +102 -0
  20. package/dist/components/text-editor/shiki/theme.d.ts +12 -0
  21. package/dist/components/text-editor/shiki/theme.js +86 -0
  22. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -34
  23. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +1 -5
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/services/group.svelte.d.ts +2 -1
  27. package/dist/services/group.svelte.js +183 -50
  28. package/dist/services/tree.svelte.d.ts +8 -6
  29. package/dist/services/tree.svelte.js +104 -45
  30. package/dist/shiki-engine.js +152 -0
  31. package/dist/typedefs.d.ts +52 -0
  32. package/dist/typedefs.js +29 -0
  33. package/package.json +13 -11
@@ -1,16 +1,12 @@
1
1
  <script>
2
- // Work around the “Prism is not defined” error in consumers
3
- // @see https://github.com/remix-run/remix/discussions/8182
4
- import 'prismjs';
5
-
6
- import { $isCodeNode as isCodeNode } from '@lexical/code';
2
+ import { $isCodeNode as isCodeNode } from '@lexical/code-core';
7
3
  import { _ } from '@sveltia/i18n';
8
4
  import { $getNodeByKey as getNodeByKey, $getRoot as getRoot } from 'lexical';
9
- import prismComponents from 'prismjs/components';
10
5
  import { getContext } from 'svelte';
11
6
  import Option from '../../listbox/option.svelte';
12
7
  import Select from '../../select/select.svelte';
13
8
  import { focusEditor, loadCodeHighlighter } from '../core.js';
9
+ import { LANGUAGES } from '../shiki/generated.js';
14
10
 
15
11
  /**
16
12
  * @import { TextEditorStore } from '../../../typedefs';
@@ -29,23 +25,11 @@
29
25
  } = $props();
30
26
 
31
27
  /** @type {{ key: string, label: string, aliases: string[] }[]} */
32
- const codeLanguages = Object.entries(prismComponents.languages)
33
- .filter(([, config]) => 'title' in config)
34
- .map(([key, val]) => {
35
- const { title: label, aliasTitles, alias } = /** @type {Record<string, any>} */ (val);
36
- let aliases = [];
37
-
38
- if (alias && !aliasTitles) {
39
- aliases = Array.isArray(alias) ? alias : [alias];
40
- }
41
-
42
- return [
43
- { key, label, aliases },
44
- ...Object.entries(aliasTitles ?? {}).map(([k, v]) => ({ key: k, label: v, aliases: [] })),
45
- ];
46
- })
47
- .flat(1)
48
- .sort((a, b) => a.label.localeCompare(b.label));
28
+ const codeLanguages = LANGUAGES.map(({ id, name, aliases = [] }) => ({
29
+ key: id,
30
+ label: name,
31
+ aliases,
32
+ }));
49
33
 
50
34
  /** @type {TextEditorStore} */
51
35
  const editorStore = getContext('editorStore');
@@ -77,20 +61,26 @@
77
61
  }
78
62
 
79
63
  await focusEditor(editorStore.editor);
64
+ await loadCodeHighlighter(lang);
80
65
 
81
- if (editorStore.selection?.blockNodeKey) {
82
- await loadCodeHighlighter(lang);
66
+ editorStore.editor.update(() => {
67
+ // Resolve the target the same way the effect above does. A code editor has exactly one code
68
+ // block, and its `blockNodeKey` is still unset the first time the switcher is used, before
69
+ // the editor has ever been focused.
70
+ // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx#L713
71
+ const { blockNodeKey } = editorStore.selection;
83
72
 
84
- editorStore.editor.update(() => {
85
- // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx#L713
86
- const node = getNodeByKey(/** @type {string} */ (editorStore.selection.blockNodeKey));
73
+ const node = editorStore.config.isCodeEditor
74
+ ? getRoot().getChildren()[0]
75
+ : blockNodeKey
76
+ ? getNodeByKey(blockNodeKey)
77
+ : null;
87
78
 
88
- if (isCodeNode(node)) {
89
- node.setLanguage(lang);
90
- selectedLanguage = lang;
91
- }
92
- });
93
- }
79
+ if (isCodeNode(node)) {
80
+ node.setLanguage(lang);
81
+ selectedLanguage = lang;
82
+ }
83
+ });
94
84
  }}
95
85
  >
96
86
  <Option label={_('_sui.text_editor.plain_text')} value="plain" dir="ltr" />
@@ -1,9 +1,5 @@
1
1
  <script>
2
- // Work around the “Prism is not defined” error in consumers
3
- // @see https://github.com/remix-run/remix/discussions/8182
4
- import 'prismjs';
5
-
6
- import { $createCodeNode as createCodeNode } from '@lexical/code';
2
+ import { $createCodeNode as createCodeNode } from '@lexical/code-core';
7
3
  import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list';
8
4
  import {
9
5
  $createHeadingNode as createHeadingNode,
package/dist/index.d.ts CHANGED
@@ -81,5 +81,9 @@ export { default as Group } from "./components/util/group.svelte";
81
81
  export { default as Modal } from "./components/util/modal.svelte";
82
82
  export { default as Placeholder } from "./components/util/placeholder.svelte";
83
83
  export { default as VisibilityObserver } from "./components/util/visibility-observer.svelte";
84
+ export { loadCodeHighlighter } from "./components/text-editor/core.js";
85
+ export { setCodeHighlighterCacheEnabled } from "./components/text-editor/shiki/cache.js";
86
+ export { highlightCodeToHTML } from "./components/text-editor/shiki/facade.js";
87
+ export { setCodeHighlighterLoaders } from "./components/text-editor/shiki/loader.js";
84
88
  export * from "./typedefs.js";
85
89
  export { initLocales, strings } from "./services/i18n.js";
package/dist/index.js CHANGED
@@ -81,6 +81,10 @@ export { default as Group } from './components/util/group.svelte';
81
81
  export { default as Modal } from './components/util/modal.svelte';
82
82
  export { default as Placeholder } from './components/util/placeholder.svelte';
83
83
  export { default as VisibilityObserver } from './components/util/visibility-observer.svelte';
84
+ export { loadCodeHighlighter } from './components/text-editor/core.js';
85
+ export { setCodeHighlighterCacheEnabled } from './components/text-editor/shiki/cache.js';
86
+ export { highlightCodeToHTML } from './components/text-editor/shiki/facade.js';
87
+ export { setCodeHighlighterLoaders } from './components/text-editor/shiki/loader.js';
84
88
  export { initLocales, strings } from './services/i18n.js';
85
89
 
86
90
  // eslint-disable-next-line import/export
@@ -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.