diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts new file mode 100644 index 000000000000..a45feaedf4ee --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -0,0 +1,435 @@ +import { + DOWN_ARROW, + EIGHT, + END, + ENTER, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {createKeyboardEvent} from '../../testing/private'; +import {QueryList} from '@angular/core'; +import {take} from 'rxjs/operators'; +import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; +import {Observable, of as observableOf} from 'rxjs'; + +class FakeBaseTreeKeyManagerItem { + _isExpanded = false; + _parent: FakeBaseTreeKeyManagerItem | null = null; + _children: FakeBaseTreeKeyManagerItem[] = []; + + isDisabled?: boolean = false; + + constructor(private _label: string) {} + + getLabel(): string { + return this._label; + } + activate(): void {} + getParent(): this | null { + return this._parent as this | null; + } + isExpanded(): boolean { + return this._isExpanded; + } + collapse(): void { + this._isExpanded = false; + } + expand(): void { + this._isExpanded = true; + } + focus(): void {} +} + +class FakeArrayTreeKeyManagerItem extends FakeBaseTreeKeyManagerItem implements TreeKeyManagerItem { + getChildren(): FakeArrayTreeKeyManagerItem[] { + return this._children as FakeArrayTreeKeyManagerItem[]; + } +} + +class FakeObservableTreeKeyManagerItem + extends FakeBaseTreeKeyManagerItem + implements TreeKeyManagerItem +{ + getChildren(): Observable { + return observableOf(this._children as FakeObservableTreeKeyManagerItem[]); + } +} + +interface ItemConstructorTestContext { + description: string; + constructor: new (label: string) => + | FakeArrayTreeKeyManagerItem + | FakeObservableTreeKeyManagerItem; +} + +describe('TreeKeyManager', () => { + let fakeKeyEvents: { + downArrow: KeyboardEvent; + upArrow: KeyboardEvent; + leftArrow: KeyboardEvent; + rightArrow: KeyboardEvent; + tab: KeyboardEvent; + home: KeyboardEvent; + end: KeyboardEvent; + enter: KeyboardEvent; + space: KeyboardEvent; + star: KeyboardEvent; + unsupported: KeyboardEvent; + }; + + beforeEach(() => { + fakeKeyEvents = { + downArrow: createKeyboardEvent('keydown', DOWN_ARROW), + upArrow: createKeyboardEvent('keydown', UP_ARROW), + leftArrow: createKeyboardEvent('keydown', LEFT_ARROW), + rightArrow: createKeyboardEvent('keydown', RIGHT_ARROW), + tab: createKeyboardEvent('keydown', TAB), + home: createKeyboardEvent('keydown', HOME), + end: createKeyboardEvent('keydown', END), + enter: createKeyboardEvent('keydown', ENTER), + space: createKeyboardEvent('keydown', SPACE), + star: createKeyboardEvent('keydown', EIGHT, '*'), + unsupported: createKeyboardEvent('keydown', 192), // corresponds to the tilde character (~) + }; + }); + + const itemParameters: ItemConstructorTestContext[] = [ + {description: 'Observable children', constructor: FakeObservableTreeKeyManagerItem}, + {description: 'array children', constructor: FakeArrayTreeKeyManagerItem}, + ]; + + for (const itemParam of itemParameters) { + describe(itemParam.description, () => { + let itemList: QueryList; + let keyManager: TreeKeyManager< + FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem + >; + + beforeEach(() => { + itemList = new QueryList(); + const parent1 = new itemParam.constructor('parent1'); + const parent1Child1 = new itemParam.constructor('parent1Child1'); + const parent1Child1Child1 = new itemParam.constructor('parent1Child1Child1'); + const parent1Child2 = new itemParam.constructor('parent1Child2'); + const parent2 = new itemParam.constructor('parent2'); + const parent2Child1 = new itemParam.constructor('parent2Child1'); + + parent1._children = [parent1Child1, parent1Child2]; + parent1Child1._parent = parent1; + parent1Child1._children = [parent1Child1Child1]; + parent1Child1Child1._parent = parent1Child1; + parent1Child2._parent = parent1; + parent2._children = [parent2Child1]; + parent2Child1._parent = parent2; + + itemList.reset([ + parent1, + parent1Child1, + parent1Child1Child1, + parent1Child2, + parent2, + parent2Child1, + ]); + keyManager = new TreeKeyManager< + FakeObservableTreeKeyManagerItem | FakeArrayTreeKeyManagerItem + >({ + items: itemList, + }); + }); + + it('should start off the activeItem as null', () => { + expect(keyManager.getActiveItem()).withContext('active item').toBeNull(); + }); + + it('should maintain the active item if the amount of items changes', () => { + keyManager.onClick(itemList.get(0)!); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + expect(keyManager.getActiveItem()?.getLabel()) + .withContext('active item label') + .toBe('parent1'); + itemList.reset([new FakeObservableTreeKeyManagerItem('parent0'), ...itemList.toArray()]); + itemList.notifyOnChanges(); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + expect(keyManager.getActiveItem()?.getLabel()) + .withContext('active item label') + .toBe('parent1'); + }); + + describe('Key events', () => { + it('should emit tabOut when the tab key is pressed', () => { + const spy = jasmine.createSpy('tabOut spy'); + keyManager.tabOut.pipe(take(1)).subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit tabOut when the tab key is pressed with a modifier', () => { + const spy = jasmine.createSpy('tabOut spy'); + keyManager.tabOut.pipe(take(1)).subscribe(spy); + + Object.defineProperty(fakeKeyEvents.tab, 'shiftKey', {get: () => true}); + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit an event whenever the active item changes', () => { + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(spy).toHaveBeenCalledTimes(1); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(spy).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + }); + + it('should emit if the active item changed, but not the active index', () => { + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + itemList.reset([new itemParam.constructor('zero'), ...itemList.toArray()]); + itemList.notifyOnChanges(); + keyManager.onClick(itemList.get(0)!); + + expect(spy).toHaveBeenCalledTimes(1); + subscription.unsubscribe(); + }); + + it('should activate the first item when pressing down on a clean key manager', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('default focused item index') + .toBe(-1); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('focused item index, after down arrow') + .toBe(0); + }); + + it('should not prevent the default keyboard action when pressing tab', () => { + expect(fakeKeyEvents.tab.defaultPrevented).toBe(false); + + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(fakeKeyEvents.tab.defaultPrevented).toBe(false); + }); + + it('should not do anything for unsupported key presses', () => { + keyManager.onClick(itemList.get(1)!); + + expect(keyManager.getActiveItemIndex()).toBe(1); + expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); + + keyManager.onKeydown(fakeKeyEvents.unsupported); + + expect(keyManager.getActiveItemIndex()).toBe(1); + expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); + }); + + it('should focus the first item when Home is pressed', () => { + keyManager.onClick(itemList.get(1)!); + expect(keyManager.getActiveItemIndex()).toBe(1); + + keyManager.onKeydown(fakeKeyEvents.home); + + expect(keyManager.getActiveItemIndex()).toBe(0); + }); + + it('should focus the last item when End is pressed', () => { + keyManager.onClick(itemList.get(0)!); + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.end); + expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 1); + }); + }); + + describe('up/down key events', () => { + it('should set subsequent items as active when the down key is pressed', () => { + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(2)); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after two down key events.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should set first item active when the down key is pressed if no active item', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after down key if active item was null') + .toBe(0); + }); + + it('should set previous item as active when the up key is pressed', () => { + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down and one up key event.') + .toBe(0); + expect(spy).toHaveBeenCalledWith(itemList.get(0)); + + subscription.unsubscribe(); + }); + + it('should do nothing when the up key is pressed if no active item', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.upArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, if up event occurs and no active item.') + .toBe(-1); + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should skip disabled items', () => { + itemList.get(1)!.isDisabled = true; + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + // down event should skip past disabled item from 0 to 2 + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, skipping past disabled item on down event.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + + // up event should skip past disabled item from 2 to 0 + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, skipping past disabled item on up event.') + .toBe(0); + expect(spy).toHaveBeenCalledWith(itemList.get(0)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should work normally when disabled property does not exist', () => { + itemList.get(0)!.isDisabled = undefined; + itemList.get(1)!.isDisabled = undefined; + itemList.get(2)!.isDisabled = undefined; + keyManager.onClick(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down event when disabled is not set.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(2)); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after two down events when disabled is not set.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should not move active item past either end of the list', () => { + keyManager.onClick(itemList.get(itemList.length - 1)!); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, selecting the last item') + .toBe(itemList.length - 1); + + // This down event would move active item past the end of the list + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, last item still selected after a down event') + .toBe(itemList.length - 1); + + keyManager.onClick(itemList.get(0)!); + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, selecting the first item') + .toBe(0); + + // This up event would move active item past the beginning of the list + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, first item still selected after a up event') + .toBe(0); + }); + + it('should not move active item to end when the last item is disabled', () => { + itemList.get(itemList.length - 1)!.isDisabled = true; + + keyManager.onClick(itemList.get(itemList.length - 2)!); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, last non-disabled item selected') + .toBe(itemList.length - 2); + + // This down key event would set active item to the last item, which is disabled + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext( + 'active item index, last non-disabled item still selected, after down event', + ) + .toBe(itemList.length - 2); + }); + + it('should prevent the default keyboard action of handled events', () => { + expect(fakeKeyEvents.downArrow.defaultPrevented).toBe(false); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(fakeKeyEvents.downArrow.defaultPrevented).toBe(true); + + expect(fakeKeyEvents.upArrow.defaultPrevented).toBe(false); + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(fakeKeyEvents.upArrow.defaultPrevented).toBe(true); + }); + }); + }); + } +}); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts new file mode 100644 index 000000000000..ef3592b2a180 --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -0,0 +1,336 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {QueryList} from '@angular/core'; +import {isObservable, Observable, Subject} from 'rxjs'; + +/** Represents an item within a tree that can be passed to a TreeKeyManager. */ +export interface TreeKeyManagerItem { + /** Whether the item is disabled. */ + isDisabled?: (() => boolean) | boolean; + + /** The user-facing label for this item. */ + getLabel?(): string; + + /** Perform the main action (i.e. selection) for this item. */ + activate(): void; + + /** Retrieves the parent for this item. This is `null` if there is no parent. */ + getParent(): TreeKeyManagerItem | null; + + /** Retrieves the children for this item. */ + getChildren(): TreeKeyManagerItem[] | Observable; + + /** Determines if the item is currently expanded. */ + isExpanded: (() => boolean) | boolean; + + /** Collapses the item, hiding its children. */ + collapse(): void; + + /** Expands the item, showing its children. */ + expand(): void; + + /** + * Focuses the item. This should provide some indication to the user that this item is focused. + */ + focus(): void; +} + +export interface TreeKeyManagerOptions { + items: Observable | QueryList | T[]; + + /** + * Sets the predicate function that determines which items should be skipped by the tree key + * manager. By default, disabled items are skipped. + * + * If the item is to be skipped, this function should return false. + */ + skipPredicate?: (item: T) => boolean; + + /** + * If true, then the key manager will call `activate` in addition to calling `focus` when a + * particular item is focused. By default, this is false. + */ + activationFollowsFocus?: boolean; + + /** + * The direction in which the tree items are laid out horizontally. This influences which key + * will be interpreted as expand or collapse. Defaults to 'ltr'. + */ + horizontalOrientation?: 'rtl' | 'ltr'; + + /** + * If provided, determines how the key manager determines if two items are equivalent. + * + * It should provide a unique key for each unique tree item. If two tree items are equivalent, + * then this function should return the same value. + */ + trackBy?: (treeItem: T) => unknown; + + /** + * If a value is provided, enables typeahead mode, which allows users to set the active item + * by typing the visible label of the item. + * + * If a number is provided, this will be the time to wait after the last keystroke before + * setting the active item. If `true` is provided, the default interval of 200ms will be used. + */ + typeAheadDebounceInterval?: true | number; +} + +/** + * This class manages keyboard events for trees. If you pass it a QueryList or other list of tree + * items, it will set the active item, focus, handle expansion and typeahead correctly when + * keyboard events occur. + */ +export class TreeKeyManager { + private _activeItemIndex = -1; + private _activeItem: T | null = null; + private _activationFollowsFocus = false; + private _horizontal: 'ltr' | 'rtl' = 'ltr'; + + /** + * Predicate function that can be used to check whether an item should be skipped + * by the key manager. By default, disabled items are skipped. + */ + private _skipPredicateFn = (item: T) => + typeof item.isDisabled === 'boolean' ? item.isDisabled : !!item.isDisabled?.(); + + /** Function to determine equivalent items. */ + private _trackByFn: (item: T) => unknown = (item: T) => item; + + private _items: T[] = []; + + constructor({ + items, + skipPredicate, + trackBy, + horizontalOrientation, + activationFollowsFocus, + typeAheadDebounceInterval, + }: TreeKeyManagerOptions) { + if (typeof skipPredicate !== 'undefined') { + this._skipPredicateFn = skipPredicate; + } + if (typeof trackBy !== 'undefined') { + this._trackByFn = trackBy; + } + if (typeof horizontalOrientation !== 'undefined') { + this._horizontal = horizontalOrientation; + } + if (typeof activationFollowsFocus !== 'undefined') { + this._activationFollowsFocus = activationFollowsFocus; + } + + // We allow for the items to be an array or Observable because, in some cases, the consumer may + // not have access to a QueryList of the items they want to manage (e.g. when the + // items aren't being collected via `ViewChildren` or `ContentChildren`). + if (items instanceof QueryList) { + this._items = items.toArray(); + items.changes.subscribe((newItems: QueryList) => { + this._items = newItems.toArray(); + this._updateActiveItemIndex(this._items); + }); + } else if (isObservable(items)) { + items.subscribe(newItems => { + this._items = newItems; + this._updateActiveItemIndex(newItems); + }); + } else { + this._items = items; + } + } + + /** + * Stream that emits any time the TAB key is pressed, so components can react + * when focus is shifted off of the list. + */ + readonly tabOut = new Subject(); + + /** Stream that emits any time the focused item changes. */ + readonly change = new Subject(); + + /** + * Handles a keyboard event on the tree. + * @param event Keyboard event that represents the user interaction with the tree. + */ + onKeydown(event: KeyboardEvent) { + const keyCode = event.keyCode; + + switch (keyCode) { + case TAB: + this.tabOut.next(); + // NB: return here, in order to allow Tab to actually tab out of the tree + return; + + case DOWN_ARROW: + this._focusNextItem(); + break; + + case UP_ARROW: + this._focusPreviousItem(); + break; + + case RIGHT_ARROW: + this._horizontal === 'rtl' ? this._collapseCurrentItem() : this._expandCurrentItem(); + break; + + case LEFT_ARROW: + this._horizontal === 'rtl' ? this._expandCurrentItem() : this._collapseCurrentItem(); + break; + + case HOME: + this._focusFirstItem(); + break; + + case END: + this._focusLastItem(); + break; + + case ENTER: + case SPACE: + this._activateCurrentItem(); + break; + + default: + // The keyCode for `*` is the same as the keyCode for `8`, so we check the event key + // instead. + if (event.key === '*') { + this._expandAllItemsAtCurrentItemLevel(); + break; + } + + // NB: return here, in order to avoid preventing the default action of non-navigational + // keys or resetting the buffer of pressed letters. + return; + } + + event.preventDefault(); + } + + /** + * Handles a mouse click on a particular tree item. + * @param treeItem The item that was clicked by the user. + */ + onClick(treeItem: T) { + this._setActiveItem(treeItem); + } + + /** Index of the currently active item. */ + getActiveItemIndex(): number | null { + return this._activeItemIndex; + } + + /** The currently active item. */ + getActiveItem(): T | null { + return this._activeItem; + } + + private _setActiveItem(index: number): void; + private _setActiveItem(item: T): void; + private _setActiveItem(itemOrIndex: number | T) { + let index = + typeof itemOrIndex === 'number' + ? itemOrIndex + : this._items.findIndex(item => this._trackByFn(item) === this._trackByFn(itemOrIndex)); + if (index < 0 || index >= this._items.length) { + return; + } + const activeItem = this._items[index]; + + // If we're just setting the same item, don't re-call activate or focus + if ( + this._activeItem !== null && + this._trackByFn(activeItem) === this._trackByFn(this._activeItem) + ) { + return; + } + + this._activeItem = activeItem ?? null; + this._activeItemIndex = index; + + this.change.next(this._activeItem); + this._activeItem?.focus(); + if (this._activationFollowsFocus) { + this._activateCurrentItem(); + } + } + + private _updateActiveItemIndex(newItems: T[]) { + if (this._activeItem) { + const newIndex = newItems.indexOf(this._activeItem); + + if (newIndex > -1 && newIndex !== this._activeItemIndex) { + this._activeItemIndex = newIndex; + } + } + } + + //// Navigational methods + + private _focusFirstItem() { + this._setActiveItem(this._findNextAvailableItemIndex(-1)); + } + + private _focusLastItem() { + this._setActiveItem(this._findPreviousAvailableItemIndex(this._items.length)); + } + + private _focusPreviousItem() { + this._setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); + } + + private _focusNextItem() { + this._setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex)); + } + + private _findNextAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex + 1; i < this._items.length; i++) { + if (!this._skipPredicateFn(this._items[i])) { + return i; + } + } + return startingIndex; + } + + private _findPreviousAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex - 1; i >= 0; i--) { + if (!this._skipPredicateFn(this._items[i])) { + return i; + } + } + return startingIndex; + } + + /** + * If the item is already expanded, we collapse the item. Otherwise, we will focus the parent. + */ + private _collapseCurrentItem() {} + + /** + * If the item is already collapsed, we expand the item. Otherwise, we will focus the first child. + */ + private _expandCurrentItem() {} + + /** For all items that are the same level as the current item, we expand those items. */ + private _expandAllItemsAtCurrentItemLevel() {} + + private _activateCurrentItem() { + this._activeItem?.activate(); + } +}