@sveltia/ui 0.53.1 → 0.55.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.
@@ -0,0 +1,722 @@
1
+ import { isRTL } from '@sveltia/i18n';
2
+ import { generateElementId } from '@sveltia/utils/element';
3
+ import { sleep } from '@sveltia/utils/misc';
4
+ import { normalize } from './group.svelte.js';
5
+ import { getSelectedItemDetail } from './select.svelte.js';
6
+
7
+ /**
8
+ * @import { Attachment } from 'svelte/attachments';
9
+ */
10
+
11
+ /**
12
+ * How long to wait, in milliseconds, before the type-ahead search terms are reset.
13
+ */
14
+ const TYPE_AHEAD_TIMEOUT = 500;
15
+ /**
16
+ * CSS selector to retrieve the tree items.
17
+ */
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
+
24
+ /**
25
+ * Implement keyboard and mouse interactions for the `tree` composite widget, following the ARIA
26
+ * Tree View pattern. Unlike the other composite widgets handled by the `Group` class, a tree
27
+ * manages the focus (roving `tabindex`) and the selection separately, and it also supports
28
+ * expanding and collapsing parent nodes.
29
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
30
+ */
31
+ export class Tree {
32
+ /**
33
+ * Initialize a new `Tree` instance.
34
+ * @param {HTMLElement} parent Parent element.
35
+ * @param {object} [options] Options.
36
+ * @param {boolean} [options.clickToSelect] Whether to select an item by clicking on it.
37
+ * @param {boolean} [options.selectionFollowsFocus] Whether to select an item as soon as it
38
+ * receives focus. Default: `true` on a single-select tree, `false` on a multi-select tree.
39
+ * @param {boolean} [options.expandOnSelect] Whether to expand or collapse a parent item when the
40
+ * item itself, rather than its chevron, is clicked or activated.
41
+ */
42
+ constructor(parent, { clickToSelect = true, selectionFollowsFocus, expandOnSelect = true } = {}) {
43
+ parent.dispatchEvent(new CustomEvent('Initializing'));
44
+
45
+ this.parent = parent;
46
+ this.id = generateElementId('tree');
47
+ this.clickToSelect = clickToSelect;
48
+ this.expandOnSelect = expandOnSelect;
49
+
50
+ /**
51
+ * Whether the selection follows the focus. `undefined` means auto detect.
52
+ * @type {boolean | undefined}
53
+ */
54
+ this.selectionFollowsFocusOption = selectionFollowsFocus;
55
+
56
+ /**
57
+ * Item used as the starting point of a range selection.
58
+ * @type {HTMLElement | undefined}
59
+ */
60
+ this.anchor = undefined;
61
+
62
+ /**
63
+ * Currently accumulated type-ahead search terms.
64
+ * @type {string}
65
+ */
66
+ this.typeAheadTerms = '';
67
+
68
+ /**
69
+ * Timer used to reset the type-ahead search terms.
70
+ * @type {ReturnType<typeof globalThis.setTimeout> | undefined}
71
+ */
72
+ this.typeAheadTimer = undefined;
73
+
74
+ // eslint-disable-next-line jsdoc/require-description
75
+ /** @type {(event: MouseEvent) => void} */
76
+ this._onClick = (event) => {
77
+ this.onClick(event);
78
+ };
79
+
80
+ // eslint-disable-next-line jsdoc/require-description
81
+ /** @type {(event: KeyboardEvent) => void} */
82
+ this._onKeyDown = (event) => {
83
+ this.onKeyDown(event);
84
+ };
85
+
86
+ // eslint-disable-next-line jsdoc/require-description
87
+ /** @type {(event: FocusEvent) => void} */
88
+ this._onFocusIn = (event) => {
89
+ this.onFocusIn(event);
90
+ };
91
+
92
+ // The items can be added or removed at any time, e.g. when a subtree is lazily rendered
93
+ this.observer = new globalThis.MutationObserver(() => {
94
+ this.update();
95
+ });
96
+
97
+ // The widget root itself is never part of the tab order; one of the items always is
98
+ this.parent.tabIndex = -1;
99
+
100
+ // Wait a bit before the child components are mounted
101
+ (async () => {
102
+ await sleep(100);
103
+ this.activate();
104
+ })();
105
+ }
106
+
107
+ /**
108
+ * Activate the items.
109
+ */
110
+ activate() {
111
+ const { parent } = this;
112
+
113
+ this.update();
114
+
115
+ parent.addEventListener('click', this._onClick);
116
+ parent.addEventListener('keydown', this._onKeyDown);
117
+ parent.addEventListener('focusin', this._onFocusIn);
118
+ this.observer.observe(parent, { childList: true, subtree: true });
119
+ parent.dispatchEvent(new CustomEvent('Initialized'));
120
+ }
121
+
122
+ /**
123
+ * Whether more than one item can be selected.
124
+ * @type {boolean}
125
+ */
126
+ get multi() {
127
+ return this.parent.getAttribute('aria-multiselectable') === 'true';
128
+ }
129
+
130
+ /**
131
+ * Whether an item is selected as soon as it receives focus.
132
+ * @type {boolean}
133
+ */
134
+ get selectionFollowsFocus() {
135
+ return this.selectionFollowsFocusOption ?? !this.multi;
136
+ }
137
+
138
+ /**
139
+ * Whether the widget is disabled.
140
+ * @type {boolean}
141
+ */
142
+ get isDisabled() {
143
+ return this.parent.matches('[aria-disabled="true"]');
144
+ }
145
+
146
+ /**
147
+ * Whether the widget is read-only.
148
+ * @type {boolean}
149
+ */
150
+ get isReadOnly() {
151
+ return this.parent.matches('[aria-readonly="true"]');
152
+ }
153
+
154
+ /**
155
+ * List of all the items, including the ones within a collapsed parent, in document order.
156
+ * @type {HTMLElement[]}
157
+ */
158
+ get allItems() {
159
+ return /** @type {HTMLElement[]} */ ([...this.parent.querySelectorAll(ITEM_SELECTOR)]);
160
+ }
161
+
162
+ /**
163
+ * List of the items that are not hidden, either explicitly or by a collapsed ancestor.
164
+ * @type {HTMLElement[]}
165
+ */
166
+ get visibleItems() {
167
+ return this.allItems.filter(
168
+ (item) => !item.matches('[hidden], [aria-hidden="true"]') && !this.hasCollapsedAncestor(item),
169
+ );
170
+ }
171
+
172
+ /**
173
+ * List of the items that can receive focus.
174
+ * @type {HTMLElement[]}
175
+ */
176
+ get activeItems() {
177
+ return this.visibleItems.filter((item) => !item.matches('[aria-disabled="true"]'));
178
+ }
179
+
180
+ /**
181
+ * List of the selected items.
182
+ * @type {HTMLElement[]}
183
+ */
184
+ get selectedItems() {
185
+ return this.allItems.filter((item) => item.matches('[aria-selected="true"]'));
186
+ }
187
+
188
+ /**
189
+ * Item that is currently in the tab order, which is the item that has or last had focus.
190
+ * @type {HTMLElement | undefined}
191
+ */
192
+ get currentItem() {
193
+ return this.allItems.find((item) => item.tabIndex === 0);
194
+ }
195
+
196
+ /**
197
+ * Get the group element that contains the child items of the given item.
198
+ * @param {HTMLElement} item Parent item.
199
+ * @returns {HTMLElement | undefined} Group element, if the item has one.
200
+ */
201
+ getGroup(item) {
202
+ return /** @type {HTMLElement | undefined} */ (
203
+ [...item.children].find((child) => child.getAttribute('role') === 'group')
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Get the child items of the given item.
209
+ * @param {HTMLElement} item Parent item.
210
+ * @returns {HTMLElement[]} Child items. Empty if the item is a leaf node.
211
+ */
212
+ getChildItems(item) {
213
+ const group = this.getGroup(item);
214
+
215
+ return group ? this.getItemsInGroup(group) : [];
216
+ }
217
+
218
+ /**
219
+ * Get the items directly owned by the given group, ignoring any deeper descendants.
220
+ * @param {HTMLElement} group Group element or the widget root.
221
+ * @returns {HTMLElement[]} Child items.
222
+ */
223
+ getItemsInGroup(group) {
224
+ return /** @type {HTMLElement[]} */ ([...group.querySelectorAll(ITEM_SELECTOR)]).filter(
225
+ (item) => item.parentElement?.closest(GROUP_SELECTOR) === group,
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Get the parent item of the given item.
231
+ * @param {HTMLElement} item Item.
232
+ * @returns {HTMLElement | undefined} Parent item, if the item is not at the root level.
233
+ */
234
+ getParentItem(item) {
235
+ return /** @type {HTMLElement | undefined} */ (
236
+ item.parentElement?.closest(ITEM_SELECTOR) ?? undefined
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Whether the given item is a parent node that can be expanded and collapsed.
242
+ * @param {HTMLElement} item Item.
243
+ * @returns {boolean} Result.
244
+ */
245
+ isParent(item) {
246
+ return item.hasAttribute('aria-expanded');
247
+ }
248
+
249
+ /**
250
+ * Whether the given parent item is expanded.
251
+ * @param {HTMLElement} item Item.
252
+ * @returns {boolean} Result.
253
+ */
254
+ isExpanded(item) {
255
+ return item.getAttribute('aria-expanded') === 'true';
256
+ }
257
+
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
+ /**
278
+ * Get the text label of the given item, which is used for the type-ahead search.
279
+ * @param {HTMLElement} item Item.
280
+ * @returns {string} Label.
281
+ */
282
+ getLabel(item) {
283
+ return item.dataset.label ?? item.querySelector('.label')?.textContent ?? '';
284
+ }
285
+
286
+ /**
287
+ * Assign the element IDs, positional attributes and roving `tabindex` to the items. Called
288
+ * whenever the items are added or removed.
289
+ */
290
+ update() {
291
+ const { allItems, activeItems } = this;
292
+
293
+ allItems.forEach((item, index) => {
294
+ item.id ||= `${this.id}-item-${index + 1}`;
295
+
296
+ if (!item.hasAttribute('aria-selected')) {
297
+ item.setAttribute('aria-selected', 'false');
298
+ }
299
+ });
300
+
301
+ const groups = /** @type {HTMLElement[]} */ ([
302
+ this.parent,
303
+ ...this.parent.querySelectorAll('[role="group"]'),
304
+ ]);
305
+
306
+ groups.forEach((group) => {
307
+ const items = this.getItemsInGroup(group);
308
+
309
+ items.forEach((item, index) => {
310
+ item.setAttribute('aria-posinset', String(index + 1));
311
+ item.setAttribute('aria-setsize', String(items.length));
312
+ });
313
+ });
314
+
315
+ // Keep exactly one item in the tab order, preferring the focused, current or selected one
316
+ const current =
317
+ activeItems.find((item) => item === document.activeElement) ??
318
+ activeItems.find((item) => item.tabIndex === 0) ??
319
+ activeItems.find((item) => item.matches('[aria-selected="true"]')) ??
320
+ activeItems[0];
321
+
322
+ allItems.forEach((item) => {
323
+ item.tabIndex = item === current ? 0 : -1;
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Scroll the given element into view if needed.
329
+ * @param {HTMLElement} element Element to be scrolled into view.
330
+ */
331
+ scrollIntoView(element) {
332
+ try {
333
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
334
+ } catch {
335
+ element.scrollIntoView(true);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Move focus to the given item.
341
+ * @param {HTMLElement} item Item to be focused.
342
+ * @param {object} [options] Options.
343
+ * @param {boolean} [options.select] Whether to also select the item. Default: depends on the
344
+ * `selectionFollowsFocus` option.
345
+ */
346
+ focusItem(item, { select = this.selectionFollowsFocus } = {}) {
347
+ this.allItems.forEach((element) => {
348
+ element.tabIndex = element === item ? 0 : -1;
349
+ });
350
+
351
+ item.focus();
352
+ item.dispatchEvent(new CustomEvent('Focus'));
353
+ this.scrollIntoView(item);
354
+
355
+ if (select) {
356
+ this.selectItem(item);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Update the selection state of the given item, and notify the change if needed.
362
+ * @param {HTMLElement} item Item.
363
+ * @param {boolean} selected Whether to select the item.
364
+ */
365
+ setSelected(item, selected) {
366
+ if (item.matches('[aria-selected="true"]') === selected) {
367
+ return;
368
+ }
369
+
370
+ item.setAttribute('aria-selected', String(selected));
371
+ item.dispatchEvent(new CustomEvent('Change', { detail: { selected } }));
372
+
373
+ if (selected) {
374
+ item.dispatchEvent(new CustomEvent('Select'));
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Select the given item.
380
+ * @param {HTMLElement} item Item to be selected.
381
+ * @param {object} [options] Options.
382
+ * @param {boolean} [options.additive] Whether to toggle the item without deselecting the other
383
+ * items. Multi-select only.
384
+ * @param {boolean} [options.range] Whether to select all the items between the anchor and the
385
+ * given item. Multi-select only.
386
+ */
387
+ selectItem(item, { additive = false, range = false } = {}) {
388
+ if (this.isDisabled || this.isReadOnly) {
389
+ return;
390
+ }
391
+
392
+ const { multi, anchor } = this;
393
+
394
+ if (multi && range && anchor && anchor !== item) {
395
+ const items = this.visibleItems;
396
+ const indexes = [items.indexOf(anchor), items.indexOf(item)].sort((a, b) => a - b);
397
+
398
+ items.forEach((element, index) => {
399
+ this.setSelected(
400
+ element,
401
+ index >= indexes[0] && index <= indexes[1] && !element.matches('[aria-disabled="true"]'),
402
+ );
403
+ });
404
+ } else if (multi && additive) {
405
+ this.setSelected(item, !item.matches('[aria-selected="true"]'));
406
+ this.anchor = item;
407
+ } else {
408
+ this.allItems.forEach((element) => {
409
+ this.setSelected(element, element === item);
410
+ });
411
+
412
+ this.anchor = item;
413
+ }
414
+
415
+ this.parent.dispatchEvent(new CustomEvent('Change', { detail: getSelectedItemDetail(item) }));
416
+ }
417
+
418
+ /**
419
+ * Select all the items that are currently displayed. Multi-select only.
420
+ * @param {HTMLElement} item Item that triggered the action.
421
+ */
422
+ selectAll(item) {
423
+ if (this.isDisabled || this.isReadOnly || !this.multi) {
424
+ return;
425
+ }
426
+
427
+ this.activeItems.forEach((element) => {
428
+ this.setSelected(element, true);
429
+ });
430
+
431
+ this.parent.dispatchEvent(new CustomEvent('Change', { detail: getSelectedItemDetail(item) }));
432
+ }
433
+
434
+ /**
435
+ * Expand or collapse the given parent item.
436
+ * @param {HTMLElement} item Item to be expanded or collapsed.
437
+ * @param {boolean} expanded Whether to expand the item.
438
+ */
439
+ expandItem(item, expanded) {
440
+ if (this.isDisabled || !this.isParent(item) || this.isExpanded(item) === expanded) {
441
+ return;
442
+ }
443
+
444
+ const group = this.getGroup(item);
445
+
446
+ // Update the DOM right away; the component will render the same state shortly
447
+ item.setAttribute('aria-expanded', String(expanded));
448
+
449
+ if (group) {
450
+ group.hidden = !expanded;
451
+ }
452
+
453
+ item.dispatchEvent(new CustomEvent('Expand', { detail: { expanded } }));
454
+ this.update();
455
+ }
456
+
457
+ /**
458
+ * Expand all the sibling parent items at the same level as the given item.
459
+ * @param {HTMLElement} item Item.
460
+ */
461
+ expandSiblings(item) {
462
+ const parentItem = this.getParentItem(item);
463
+ const group = parentItem ? this.getGroup(parentItem) : this.parent;
464
+
465
+ if (!group) {
466
+ return;
467
+ }
468
+
469
+ this.getItemsInGroup(group).forEach((sibling) => {
470
+ this.expandItem(sibling, true);
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Move focus to the next item that matches the accumulated type-ahead search terms.
476
+ * @param {string} char Typed character.
477
+ * @param {HTMLElement} currentItem Currently focused item.
478
+ */
479
+ typeAhead(char, currentItem) {
480
+ globalThis.clearTimeout(this.typeAheadTimer);
481
+
482
+ this.typeAheadTerms += char;
483
+ this.typeAheadTimer = globalThis.setTimeout(() => {
484
+ this.typeAheadTerms = '';
485
+ }, TYPE_AHEAD_TIMEOUT);
486
+
487
+ const terms = normalize(this.typeAheadTerms);
488
+ const items = this.activeItems;
489
+ const index = items.indexOf(currentItem);
490
+
491
+ // Start the search right after the current item, so repeatedly typing the same character
492
+ // cycles through the matches. Keep the current item first while the terms are being extended.
493
+ const orderedItems = [
494
+ ...(terms.length > 1 ? [currentItem] : []),
495
+ ...items.slice(index + 1),
496
+ ...items.slice(0, index + 1),
497
+ ];
498
+
499
+ const match = orderedItems.find((item) => normalize(this.getLabel(item)).startsWith(terms));
500
+
501
+ if (match && match !== currentItem) {
502
+ this.focusItem(match);
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Handle the `focusin` event on the widget. Make the newly focused item the only one in the tab
508
+ * order, so that Shift+Tab and Tab move focus out of the widget.
509
+ * @param {FocusEvent} event `focusin` event.
510
+ */
511
+ onFocusIn(event) {
512
+ const item = /** @type {HTMLElement | null} */ (
513
+ /** @type {HTMLElement} */ (event.target).closest(ITEM_SELECTOR)
514
+ );
515
+
516
+ if (!item) {
517
+ return;
518
+ }
519
+
520
+ this.allItems.forEach((element) => {
521
+ element.tabIndex = element === item ? 0 : -1;
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Handle the `click` event on the widget.
527
+ * @param {MouseEvent} event `click` event.
528
+ */
529
+ onClick(event) {
530
+ // eslint-disable-next-line prefer-destructuring
531
+ const target = /** @type {HTMLElement} */ (event.target);
532
+ const item = /** @type {HTMLElement | null} */ (target.closest(ITEM_SELECTOR));
533
+
534
+ if (!item || event.button !== 0 || this.isDisabled) {
535
+ return;
536
+ }
537
+
538
+ if (item.matches('[aria-disabled="true"]')) {
539
+ event.preventDefault();
540
+
541
+ return;
542
+ }
543
+
544
+ const { ctrlKey, metaKey, shiftKey } = event;
545
+
546
+ // The chevron only expands or collapses the item
547
+ if (target.closest('[data-action="toggle"]')) {
548
+ event.preventDefault();
549
+ this.focusItem(item, { select: false });
550
+ this.expandItem(item, !this.isExpanded(item));
551
+
552
+ return;
553
+ }
554
+
555
+ this.focusItem(item, { select: false });
556
+
557
+ if (this.clickToSelect) {
558
+ this.selectItem(item, { additive: ctrlKey || metaKey, range: shiftKey });
559
+ }
560
+
561
+ if (this.expandOnSelect && !ctrlKey && !metaKey && !shiftKey) {
562
+ this.expandItem(item, !this.isExpanded(item));
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Handle the `keydown` event on the widget.
568
+ * @param {KeyboardEvent} event `keydown` event.
569
+ */
570
+ onKeyDown(event) {
571
+ const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
572
+ const { activeItems, multi } = this;
573
+
574
+ if (this.isDisabled || altKey || !activeItems.length) {
575
+ return;
576
+ }
577
+
578
+ const target = /** @type {HTMLElement | null} */ (
579
+ /** @type {HTMLElement} */ (event.target).closest(ITEM_SELECTOR)
580
+ );
581
+
582
+ const currentItem = target ?? this.currentItem ?? activeItems[0];
583
+ const index = activeItems.indexOf(currentItem);
584
+ // In RTL, the Left and Right arrow keys are swapped
585
+ const forwardKey = isRTL() ? 'ArrowLeft' : 'ArrowRight';
586
+ const backwardKey = isRTL() ? 'ArrowRight' : 'ArrowLeft';
587
+
588
+ if (key === 'a' && (ctrlKey || metaKey)) {
589
+ if (multi) {
590
+ event.preventDefault();
591
+ this.selectAll(currentItem);
592
+ }
593
+
594
+ return;
595
+ }
596
+
597
+ if (ctrlKey || metaKey) {
598
+ return;
599
+ }
600
+
601
+ if (key === 'Enter') {
602
+ event.preventDefault();
603
+ // Also trigger any custom `onclick` handler on the item
604
+ currentItem.click();
605
+
606
+ return;
607
+ }
608
+
609
+ if (key === ' ') {
610
+ event.preventDefault();
611
+
612
+ if (multi) {
613
+ this.selectItem(currentItem, { additive: !shiftKey, range: shiftKey });
614
+ } else {
615
+ this.selectItem(currentItem);
616
+ }
617
+
618
+ return;
619
+ }
620
+
621
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
622
+ event.preventDefault();
623
+
624
+ const newItem = activeItems[key === 'ArrowDown' ? index + 1 : index - 1];
625
+
626
+ if (!newItem) {
627
+ return;
628
+ }
629
+
630
+ if (multi && shiftKey) {
631
+ this.focusItem(newItem, { select: false });
632
+ this.selectItem(newItem, { additive: true });
633
+ } else {
634
+ this.focusItem(newItem);
635
+ }
636
+
637
+ return;
638
+ }
639
+
640
+ if (key === forwardKey) {
641
+ event.preventDefault();
642
+
643
+ if (!this.isParent(currentItem)) {
644
+ return;
645
+ }
646
+
647
+ if (!this.isExpanded(currentItem)) {
648
+ this.expandItem(currentItem, true);
649
+
650
+ return;
651
+ }
652
+
653
+ const childItem = this.getChildItems(currentItem).find((item) => activeItems.includes(item));
654
+
655
+ if (childItem) {
656
+ this.focusItem(childItem);
657
+ }
658
+
659
+ return;
660
+ }
661
+
662
+ if (key === backwardKey) {
663
+ event.preventDefault();
664
+
665
+ if (this.isParent(currentItem) && this.isExpanded(currentItem)) {
666
+ this.expandItem(currentItem, false);
667
+
668
+ return;
669
+ }
670
+
671
+ const parentItem = this.getParentItem(currentItem);
672
+
673
+ if (parentItem && activeItems.includes(parentItem)) {
674
+ this.focusItem(parentItem);
675
+ }
676
+
677
+ return;
678
+ }
679
+
680
+ if (key === 'Home' || key === 'End') {
681
+ event.preventDefault();
682
+ this.focusItem(key === 'Home' ? activeItems[0] : activeItems[activeItems.length - 1]);
683
+
684
+ return;
685
+ }
686
+
687
+ if (key === '*') {
688
+ event.preventDefault();
689
+ this.expandSiblings(currentItem);
690
+
691
+ return;
692
+ }
693
+
694
+ if (key.length === 1 && key.trim()) {
695
+ this.typeAhead(key, currentItem);
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Clean up event listeners.
701
+ */
702
+ destroy() {
703
+ globalThis.clearTimeout(this.typeAheadTimer);
704
+ this.observer.disconnect();
705
+ this.parent.removeEventListener('click', this._onClick);
706
+ this.parent.removeEventListener('keydown', this._onKeyDown);
707
+ this.parent.removeEventListener('focusin', this._onFocusIn);
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Activate a new tree.
713
+ * @param {object} [params] Params to be passed to the `Tree` constructor.
714
+ * @returns {Attachment} Attachment.
715
+ */
716
+ export const activateTree = (params) => (parent) => {
717
+ const tree = new Tree(/** @type {HTMLElement} */ (parent), params);
718
+
719
+ return () => {
720
+ tree.destroy();
721
+ };
722
+ };