UNPKG

@angular/cdk

Version:

Angular Material Component Development Kit

361 lines (358 loc) 13.5 kB
import { QueryList, InjectionToken } from '@angular/core'; import { Subscription, isObservable, Subject, of } from 'rxjs'; import { take } from 'rxjs/operators'; import { T as Typeahead } from './typeahead-0113d27c.mjs'; import { c as coerceObservable } from './observable-3cba8a1c.mjs'; /** * 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. */ class TreeKeyManager { /** The index of the currently active (focused) item. */ _activeItemIndex = -1; /** The currently active (focused) item. */ _activeItem = null; /** Whether or not we activate the item when it's focused. */ _shouldActivationFollowFocus = false; /** * The orientation that the tree is laid out in. In `rtl` mode, the behavior of Left and * Right arrow are switched. */ _horizontalOrientation = 'ltr'; /** * Predicate function that can be used to check whether an item should be skipped * by the key manager. * * The default value for this doesn't skip any elements in order to keep tree items focusable * when disabled. This aligns with ARIA guidelines: * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols. */ _skipPredicateFn = (_item) => false; /** Function to determine equivalent items. */ _trackByFn = (item) => item; /** Synchronous cache of the items to manage. */ _items = []; _typeahead; _typeaheadSubscription = Subscription.EMPTY; _hasInitialFocused = false; _initializeFocus() { if (this._hasInitialFocused || this._items.length === 0) { return; } let activeIndex = 0; for (let i = 0; i < this._items.length; i++) { if (!this._skipPredicateFn(this._items[i]) && !this._isItemDisabled(this._items[i])) { activeIndex = i; break; } } const activeItem = this._items[activeIndex]; // Use `makeFocusable` here, because we want the item to just be focusable, not actually // capture the focus since the user isn't interacting with it. See #29628. if (activeItem.makeFocusable) { this._activeItem?.unfocus(); this._activeItemIndex = activeIndex; this._activeItem = activeItem; this._typeahead?.setCurrentSelectedItemIndex(activeIndex); activeItem.makeFocusable(); } else { // Backwards compatibility for items that don't implement `makeFocusable`. this.focusItem(activeIndex); } this._hasInitialFocused = true; } /** * * @param items List of TreeKeyManager options. Can be synchronous or asynchronous. * @param config Optional configuration options. By default, use 'ltr' horizontal orientation. By * default, do not skip any nodes. By default, key manager only calls `focus` method when items * are focused and does not call `activate`. If `typeaheadDefaultInterval` is `true`, use a * default interval of 200ms. */ constructor(items, config) { // 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) => { this._items = newItems.toArray(); this._typeahead?.setItems(this._items); this._updateActiveItemIndex(this._items); this._initializeFocus(); }); } else if (isObservable(items)) { items.subscribe(newItems => { this._items = newItems; this._typeahead?.setItems(newItems); this._updateActiveItemIndex(newItems); this._initializeFocus(); }); } else { this._items = items; this._initializeFocus(); } if (typeof config.shouldActivationFollowFocus === 'boolean') { this._shouldActivationFollowFocus = config.shouldActivationFollowFocus; } if (config.horizontalOrientation) { this._horizontalOrientation = config.horizontalOrientation; } if (config.skipPredicate) { this._skipPredicateFn = config.skipPredicate; } if (config.trackBy) { this._trackByFn = config.trackBy; } if (typeof config.typeAheadDebounceInterval !== 'undefined') { this._setTypeAhead(config.typeAheadDebounceInterval); } } /** Stream that emits any time the focused item changes. */ change = new Subject(); /** Cleans up the key manager. */ destroy() { this._typeaheadSubscription.unsubscribe(); this._typeahead?.destroy(); this.change.complete(); } /** * Handles a keyboard event on the tree. * @param event Keyboard event that represents the user interaction with the tree. */ onKeydown(event) { const key = event.key; switch (key) { case 'Tab': // Return early here, in order to allow Tab to actually tab out of the tree return; case 'ArrowDown': this._focusNextItem(); break; case 'ArrowUp': this._focusPreviousItem(); break; case 'ArrowRight': this._horizontalOrientation === 'rtl' ? this._collapseCurrentItem() : this._expandCurrentItem(); break; case 'ArrowLeft': this._horizontalOrientation === 'rtl' ? this._expandCurrentItem() : this._collapseCurrentItem(); break; case 'Home': this._focusFirstItem(); break; case 'End': this._focusLastItem(); break; case 'Enter': case ' ': this._activateCurrentItem(); break; default: if (event.key === '*') { this._expandAllItemsAtCurrentItemLevel(); break; } this._typeahead?.handleKey(event); // Return here, in order to avoid preventing the default action of non-navigational // keys or resetting the buffer of pressed letters. return; } // Reset the typeahead since the user has used a navigational key. this._typeahead?.reset(); event.preventDefault(); } /** Index of the currently active item. */ getActiveItemIndex() { return this._activeItemIndex; } /** The currently active item. */ getActiveItem() { return this._activeItem; } /** Focus the first available item. */ _focusFirstItem() { this.focusItem(this._findNextAvailableItemIndex(-1)); } /** Focus the last available item. */ _focusLastItem() { this.focusItem(this._findPreviousAvailableItemIndex(this._items.length)); } /** Focus the next available item. */ _focusNextItem() { this.focusItem(this._findNextAvailableItemIndex(this._activeItemIndex)); } /** Focus the previous available item. */ _focusPreviousItem() { this.focusItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); } focusItem(itemOrIndex, options = {}) { // Set default options options.emitChangeEvent ??= true; 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; } const previousActiveItem = this._activeItem; this._activeItem = activeItem ?? null; this._activeItemIndex = index; this._typeahead?.setCurrentSelectedItemIndex(index); this._activeItem?.focus(); previousActiveItem?.unfocus(); if (options.emitChangeEvent) { this.change.next(this._activeItem); } if (this._shouldActivationFollowFocus) { this._activateCurrentItem(); } } _updateActiveItemIndex(newItems) { const activeItem = this._activeItem; if (!activeItem) { return; } const newIndex = newItems.findIndex(item => this._trackByFn(item) === this._trackByFn(activeItem)); if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; this._typeahead?.setCurrentSelectedItemIndex(newIndex); } } _setTypeAhead(debounceInterval) { this._typeahead = new Typeahead(this._items, { debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined, skipPredicate: item => this._skipPredicateFn(item), }); this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => { this.focusItem(item); }); } _findNextAvailableItemIndex(startingIndex) { for (let i = startingIndex + 1; i < this._items.length; i++) { if (!this._skipPredicateFn(this._items[i])) { return i; } } return startingIndex; } _findPreviousAvailableItemIndex(startingIndex) { 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. */ _collapseCurrentItem() { if (!this._activeItem) { return; } if (this._isCurrentItemExpanded()) { this._activeItem.collapse(); } else { const parent = this._activeItem.getParent(); if (!parent || this._skipPredicateFn(parent)) { return; } this.focusItem(parent); } } /** * If the item is already collapsed, we expand the item. Otherwise, we will focus the first child. */ _expandCurrentItem() { if (!this._activeItem) { return; } if (!this._isCurrentItemExpanded()) { this._activeItem.expand(); } else { coerceObservable(this._activeItem.getChildren()) .pipe(take(1)) .subscribe(children => { const firstChild = children.find(child => !this._skipPredicateFn(child)); if (!firstChild) { return; } this.focusItem(firstChild); }); } } _isCurrentItemExpanded() { if (!this._activeItem) { return false; } return typeof this._activeItem.isExpanded === 'boolean' ? this._activeItem.isExpanded : this._activeItem.isExpanded(); } _isItemDisabled(item) { return typeof item.isDisabled === 'boolean' ? item.isDisabled : item.isDisabled?.(); } /** For all items that are the same level as the current item, we expand those items. */ _expandAllItemsAtCurrentItemLevel() { if (!this._activeItem) { return; } const parent = this._activeItem.getParent(); let itemsToExpand; if (!parent) { itemsToExpand = of(this._items.filter(item => item.getParent() === null)); } else { itemsToExpand = coerceObservable(parent.getChildren()); } itemsToExpand.pipe(take(1)).subscribe(items => { for (const item of items) { item.expand(); } }); } _activateCurrentItem() { this._activeItem?.activate(); } } /** * @docs-private * @deprecated No longer used, will be removed. * @breaking-change 21.0.0 */ function TREE_KEY_MANAGER_FACTORY() { return (items, options) => new TreeKeyManager(items, options); } /** Injection token that determines the key manager to use. */ const TREE_KEY_MANAGER = new InjectionToken('tree-key-manager', { providedIn: 'root', factory: TREE_KEY_MANAGER_FACTORY, }); /** * @docs-private * @deprecated No longer used, will be removed. * @breaking-change 21.0.0 */ const TREE_KEY_MANAGER_FACTORY_PROVIDER = { provide: TREE_KEY_MANAGER, useFactory: TREE_KEY_MANAGER_FACTORY, }; export { TREE_KEY_MANAGER as T, TreeKeyManager as a, TREE_KEY_MANAGER_FACTORY as b, TREE_KEY_MANAGER_FACTORY_PROVIDER as c }; //# sourceMappingURL=tree-key-manager-1212bcbe.mjs.map