From 9a381dfa6e4a5337e961efbf985dc8894b327d6a Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:20 -0500 Subject: [PATCH 01/11] feat(cdk/a11y): add API for TreeKeyManager --- src/cdk/a11y/key-manager/tree-key-manager.ts | 125 +++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/cdk/a11y/key-manager/tree-key-manager.ts 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..0efa6b19564a --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -0,0 +1,125 @@ +/** + * @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 {QueryList} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; + +// TODO(cassc): Temporarily disable tslint since this is just the raw API. +// tslint:disable + +/** Represents an item within a tree that can be passed to a TreeKeyManager. */ +export interface TreeKeyManagerItem { + /** Whether the item is disabled. */ + isDisabled?(): 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(): this | null; + + /** Retrieves the children for this item. */ + getChildren(): this[] | Observable; + + /** Determines if the item is currently expanded. */ + isExpanded(): 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 { + constructor(options: TreeKeyManagerOptions) {} + + /** + * 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(); + + /** + * Handles a keyboard event on the tree. + * @param event Keyboard event that represents the user interaction with the tree. + */ + onKeydown(event: KeyboardEvent) {} + + /** + * Handles a mouse click on a particular tree item. + * @param treeItem The item that was clicked by the user. + */ + onClick(treeItem: T) {} + + /** Index of the currently active item. */ + getActiveItemIndex(): number | null { + return null; + } + + /** The currently active item. */ + getActiveItem(): T | null { + return null; + } +} + +// tslint:enable From 02c9c859afd9697292cb3b6b693fbdc4b59cf032 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:20 -0500 Subject: [PATCH 02/11] feat(cdk/a11y): add activeItem into the TreeKeyManager --- src/cdk/a11y/key-manager/tree-key-manager.ts | 34 +++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 0efa6b19564a..aae94fca4f4d 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -7,7 +7,7 @@ */ import {QueryList} from '@angular/core'; -import {Observable, Subject} from 'rxjs'; +import {isObservable, Observable, Subject} from 'rxjs'; // TODO(cassc): Temporarily disable tslint since this is just the raw API. // tslint:disable @@ -91,7 +91,23 @@ export interface TreeKeyManagerOptions { * keyboard events occur. */ export class TreeKeyManager { - constructor(options: TreeKeyManagerOptions) {} + private _activeItemIndex = -1; + private _activeItem: T | null = null; + + constructor({items}: TreeKeyManagerOptions) { + // 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) { + items.changes.subscribe((newItems: QueryList) => { + this._updateActiveItemIndex(newItems.toArray()); + }); + } else if (isObservable(items)) { + items.subscribe(newItems => { + this._updateActiveItemIndex(newItems); + }); + } + } /** * Stream that emits any time the TAB key is pressed, so components can react @@ -113,12 +129,22 @@ export class TreeKeyManager { /** Index of the currently active item. */ getActiveItemIndex(): number | null { - return null; + return this._activeItemIndex; } /** The currently active item. */ getActiveItem(): T | null { - return null; + return this._activeItem; + } + + private _updateActiveItemIndex(newItems: T[]) { + if (this._activeItem) { + const newIndex = newItems.indexOf(this._activeItem); + + if (newIndex > -1 && newIndex !== this._activeItemIndex) { + this._activeItemIndex = newIndex; + } + } } } From 8a1fbe137bb6bc7fe9d8e117caf89db3c8187a8c Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:20 -0500 Subject: [PATCH 03/11] feat(cdk/a11y): store the options into the key manager --- src/cdk/a11y/key-manager/tree-key-manager.ts | 33 +++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index aae94fca4f4d..647d2c998df7 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -93,8 +93,39 @@ export interface TreeKeyManagerOptions { 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) => !!item.isDisabled?.(); + + /** Function to determine equivalent items. */ + private _trackByFn: (item: T) => unknown = (item: T) => item; + + 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; + } - constructor({items}: TreeKeyManagerOptions) { // 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`). From cb6f2e30e6bbb05798e6754330b72def33f873b1 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:20 -0500 Subject: [PATCH 04/11] feat(cdk/a11y): add _getItems translation layer --- src/cdk/a11y/key-manager/tree-key-manager.ts | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 647d2c998df7..d190a10ff8cf 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -105,6 +105,8 @@ export class TreeKeyManager { /** Function to determine equivalent items. */ private _trackByFn: (item: T) => unknown = (item: T) => item; + private _items: Observable | QueryList | T[]; + constructor({ items, skipPredicate, @@ -126,6 +128,8 @@ export class TreeKeyManager { this._activationFollowsFocus = activationFollowsFocus; } + this._items = items; + // 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`). @@ -168,6 +172,26 @@ export class TreeKeyManager { return this._activeItem; } + private _setActiveItem(index: number) { + this._getItems() + .pipe(take(1)) + .subscribe(items => { + const activeItem = items[index]; + + // Explicitly check for `null` and `undefined` because other falsy values are valid. + this._activeItem = activeItem == null ? null : activeItem; + this._activeItemIndex = index; + + if (!this._activeItem) { + return; + } + this._activeItem.focus(); + if (this._activationFollowsFocus) { + this._activeItem.activate(); + } + }); + } + private _updateActiveItemIndex(newItems: T[]) { if (this._activeItem) { const newIndex = newItems.indexOf(this._activeItem); @@ -177,6 +201,10 @@ export class TreeKeyManager { } } } + + private _getItems(): Observable { + return coerceObservable(this._items); + } } // tslint:enable From 8ab9b77ad4db6d2ced5c660bbd8764857d759d53 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:21 -0500 Subject: [PATCH 05/11] feat(cdk/a11y): add skeleton for keydown actions --- src/cdk/a11y/key-manager/tree-key-manager.ts | 93 +++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index d190a10ff8cf..7985b584b945 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -6,6 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ +import { + A, + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + NINE, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, + Z, + ZERO, +} from '@angular/cdk/keycodes'; import {QueryList} from '@angular/core'; import {isObservable, Observable, Subject} from 'rxjs'; @@ -154,7 +169,58 @@ export class TreeKeyManager { * Handles a keyboard event on the tree. * @param event Keyboard event that represents the user interaction with the tree. */ - onKeydown(event: KeyboardEvent) {} + onKeydown(event: KeyboardEvent) { + const keyCode = event.keyCode; + + switch (keyCode) { + case TAB: + this.tabOut.next(); + 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; + } + + // Note that we 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. @@ -205,6 +271,31 @@ export class TreeKeyManager { private _getItems(): Observable { return coerceObservable(this._items); } + + //// Navigational methods + + private _focusFirstItem() {} + + private _focusLastItem() {} + + private _focusPreviousItem() {} + + private _focusNextItem() {} + + /** + * 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() {} } // tslint:enable From 93ebe304bc9d7bef14ddd8c15cefbb8751af7f6c Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:21 -0500 Subject: [PATCH 06/11] feat(cdk/a11y): implement activate item --- src/cdk/a11y/key-manager/tree-key-manager.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 7985b584b945..ca889a4387ae 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -248,12 +248,9 @@ export class TreeKeyManager { this._activeItem = activeItem == null ? null : activeItem; this._activeItemIndex = index; - if (!this._activeItem) { - return; - } - this._activeItem.focus(); + this._activeItem?.focus(); if (this._activationFollowsFocus) { - this._activeItem.activate(); + this._activateCurrentItem(); } }); } @@ -295,7 +292,9 @@ export class TreeKeyManager { /** For all items that are the same level as the current item, we expand those items. */ private _expandAllItemsAtCurrentItemLevel() {} - private _activateCurrentItem() {} + private _activateCurrentItem() { + this._activeItem?.activate(); + } } // tslint:enable From 46ea5e4674f5412b6defd9d88df5d7db76310bc5 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:21 -0500 Subject: [PATCH 07/11] feat(cdk/a11y): implement various focus methods --- src/cdk/a11y/key-manager/tree-key-manager.ts | 33 ++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index ca889a4387ae..aee2655ca078 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -242,10 +242,19 @@ export class TreeKeyManager { this._getItems() .pipe(take(1)) .subscribe(items => { + // Clamp the index between 0 and the length of the list. + index = Math.min(Math.max(index, 0), items.length - 1); const activeItem = items[index]; - // Explicitly check for `null` and `undefined` because other falsy values are valid. - this._activeItem = activeItem == null ? null : activeItem; + // 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._activeItem?.focus(); @@ -271,13 +280,25 @@ export class TreeKeyManager { //// Navigational methods - private _focusFirstItem() {} + private _focusFirstItem() { + this._setActiveItem(0); + } - private _focusLastItem() {} + private _focusLastItem() { + this._getItems() + .pipe(take(1)) + .subscribe(items => { + this._setActiveItem(items.length - 1); + }); + } - private _focusPreviousItem() {} + private _focusPreviousItem() { + this._setActiveItem(this._activeItemIndex - 1); + } - private _focusNextItem() {} + private _focusNextItem() { + this._setActiveItem(this._activeItemIndex + 1); + } /** * If the item is already expanded, we collapse the item. Otherwise, we will focus the parent. From b5a0c57aac88a7bd01cf5f8295105ab233c57b3b Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:21 -0500 Subject: [PATCH 08/11] feat(cdk/a11y): implement onClick, remove lint disables --- src/cdk/a11y/key-manager/tree-key-manager.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index aee2655ca078..222471883abd 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -24,9 +24,6 @@ import { import {QueryList} from '@angular/core'; import {isObservable, Observable, Subject} from 'rxjs'; -// TODO(cassc): Temporarily disable tslint since this is just the raw API. -// tslint:disable - /** Represents an item within a tree that can be passed to a TreeKeyManager. */ export interface TreeKeyManagerItem { /** Whether the item is disabled. */ @@ -226,7 +223,9 @@ export class TreeKeyManager { * Handles a mouse click on a particular tree item. * @param treeItem The item that was clicked by the user. */ - onClick(treeItem: T) {} + onClick(treeItem: T) { + this._setActiveItem(treeItem); + } /** Index of the currently active item. */ getActiveItemIndex(): number | null { @@ -318,4 +317,3 @@ export class TreeKeyManager { } } -// tslint:enable From 0a5fbcd0c56c7e4bedbbabf5591e6362e31d27e2 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:21 -0500 Subject: [PATCH 09/11] feat(cdk/a11y): partial spec migration, fixed some bugs in impl --- .../a11y/key-manager/tree-key-manager.spec.ts | 979 ++++++++++++++++++ src/cdk/a11y/key-manager/tree-key-manager.ts | 126 ++- 2 files changed, 1059 insertions(+), 46 deletions(-) create mode 100644 src/cdk/a11y/key-manager/tree-key-manager.spec.ts 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..02c3e55edf35 --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -0,0 +1,979 @@ +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 {fakeAsync, tick} from '@angular/core/testing'; +import {take} from 'rxjs/operators'; +import {FocusOrigin} from '../focus-monitor/focus-monitor'; +import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; +import {Observable, of as observableOf} from 'rxjs'; + +class FakeBaseTreeKeyManagerItem { + public _isExpanded = false; + public _parent: FakeBaseTreeKeyManagerItem | null = null; + public _children: FakeBaseTreeKeyManagerItem[] = []; + + public 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; +} + +interface ExpandCollapseKeyEventTestContext { + expandKeyEvent: KeyboardEvent; + collapseKeyEvent: KeyboardEvent; +} + +fdescribe('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 + >; + + let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0 + let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1 + let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3 + + 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; + + parentItem = parent1; + childItem = parent1Child1; + childItemWithNoChildren = parent1Child2; + + 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); + }); + }); + }); + } + + // describe('programmatic focus', () => { + // it('should setActiveItem()', () => { + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.setActiveItem(1); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected activeItemIndex to be updated when setActiveItem() was called.`) + // .toBe(1); + // }); + // + // it('should be able to set the active item by reference', () => { + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.setActiveItem(itemList.toArray()[2]); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected activeItemIndex to be updated.`) + // .toBe(2); + // }); + // + // it('should be able to set the active item without emitting an event', () => { + // const spy = jasmine.createSpy('change spy'); + // const subscription = keyManager.change.subscribe(spy); + // + // expect(keyManager.activeItemIndex).toBe(0); + // + // keyManager.updateActiveItem(2); + // + // expect(keyManager.activeItemIndex).toBe(2); + // expect(spy).not.toHaveBeenCalled(); + // + // subscription.unsubscribe(); + // }); + // + // it('should expose the active item correctly', () => { + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(keyManager.activeItemIndex) + // .withContext('Expected active item to be the second option.') + // .toBe(1); + // expect(keyManager.activeItem) + // .withContext('Expected the active item to match the second option.') + // .toBe(itemList.toArray()[1]); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext('Expected active item to be the third option.') + // .toBe(2); + // expect(keyManager.activeItem) + // .withContext('Expected the active item ID to match the third option.') + // .toBe(itemList.toArray()[2]); + // }); + // + // it('should setFirstItemActive()', () => { + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected last item of the list to be active.`) + // .toBe(2); + // + // keyManager.setFirstItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setFirstItemActive() to set the active item to the first item.`) + // .toBe(0); + // }); + // + // it('should set the active item to the second item if the first one is disabled', () => { + // const items = itemList.toArray(); + // items[0].disabled = true; + // itemList.reset(items); + // + // keyManager.setFirstItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected the second item to be active if the first was disabled.`) + // .toBe(1); + // }); + // + // it('should setLastItemActive()', () => { + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.setLastItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setLastItemActive() to set the active item to the last item.`) + // .toBe(2); + // }); + // + // it('should set the active item to the second to last item if the last is disabled', () => { + // const items = itemList.toArray(); + // items[2].disabled = true; + // itemList.reset(items); + // + // keyManager.setLastItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected the second to last item to be active if the last was disabled.`) + // .toBe(1); + // }); + // + // it('should setNextItemActive()', () => { + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.setNextItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setNextItemActive() to set the active item to the next item.`) + // .toBe(1); + // }); + // + // it('should set the active item to the next enabled item if next is disabled', () => { + // const items = itemList.toArray(); + // items[1].disabled = true; + // itemList.reset(items); + // + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.setNextItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setNextItemActive() to only set enabled items as active.`) + // .toBe(2); + // }); + // + // it('should setPreviousItemActive()', () => { + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected second item of the list to be active.`) + // .toBe(1); + // + // keyManager.setPreviousItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setPreviousItemActive() to set the active item to the previous.`) + // .toBe(0); + // }); + // + // it('should skip disabled items when setPreviousItemActive() is called', () => { + // const items = itemList.toArray(); + // items[1].disabled = true; + // itemList.reset(items); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected third item of the list to be active.`) + // .toBe(2); + // + // keyManager.setPreviousItemActive(); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected setPreviousItemActive() to skip the disabled item.`) + // .toBe(0); + // }); + // + // it('should not emit an event if the item did not change', () => { + // const spy = jasmine.createSpy('change spy'); + // const subscription = keyManager.change.subscribe(spy); + // + // keyManager.setActiveItem(2); + // keyManager.setActiveItem(2); + // + // expect(spy).toHaveBeenCalledTimes(1); + // + // subscription.unsubscribe(); + // }); + // }); + // + // describe('wrap mode', () => { + // it('should return itself to allow chaining', () => { + // expect(keyManager.withWrap()) + // .withContext(`Expected withWrap() to return an instance of ListKeyManager.`) + // .toEqual(keyManager); + // }); + // + // it('should wrap focus when arrow keying past items while in wrap mode', () => { + // keyManager.withWrap(); + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(keyManager.activeItemIndex).withContext('Expected last item to be active.').toBe(2); + // + // // this down arrow moves down past the end of the list + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext('Expected active item to wrap to beginning.') + // .toBe(0); + // + // // this up arrow moves up past the beginning of the list + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // expect(keyManager.activeItemIndex) + // .withContext('Expected active item to wrap to end.') + // .toBe(2); + // }); + // + // it('should set last item active when up arrow is pressed if no active item', () => { + // keyManager.withWrap(); + // keyManager.setActiveItem(-1); + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // + // expect(keyManager.activeItemIndex) + // .withContext('Expected last item to be active on up arrow if no active item.') + // .toBe(2); + // expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + // expect(keyManager.setActiveItem).toHaveBeenCalledWith(2); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(keyManager.activeItemIndex) + // .withContext('Expected active item to be 0 after wrapping back to beginning.') + // .toBe(0); + // expect(keyManager.setActiveItem).toHaveBeenCalledWith(0); + // }); + // + // // This test should pass if all items are disabled and the down arrow key got pressed. + // // If the test setup crashes or this test times out, this test can be considered as failed. + // it('should not get into an infinite loop if all items are disabled', () => { + // keyManager.withWrap(); + // keyManager.setActiveItem(0); + // const items = itemList.toArray(); + // items.forEach(item => (item.disabled = true)); + // itemList.reset(items); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // }); + // + // it('should be able to disable wrapping', () => { + // keyManager.withWrap(); + // keyManager.setFirstItemActive(); + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // + // expect(keyManager.activeItemIndex).toBe(itemList.length - 1); + // + // keyManager.withWrap(false); + // keyManager.setFirstItemActive(); + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // + // expect(keyManager.activeItemIndex).toBe(0); + // }); + // }); + // + // describe('skip predicate', () => { + // it('should skip disabled items by default', () => { + // const items = itemList.toArray(); + // items[1].disabled = true; + // itemList.reset(items); + // + // expect(keyManager.activeItemIndex).toBe(0); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(keyManager.activeItemIndex).toBe(2); + // }); + // + // it('should be able to skip items with a custom predicate', () => { + // keyManager.skipPredicate(item => item.skipItem); + // + // const items = itemList.toArray(); + // items[1].skipItem = true; + // itemList.reset(items); + // + // expect(keyManager.activeItemIndex).toBe(0); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(keyManager.activeItemIndex).toBe(2); + // }); + // }); + // + // describe('typeahead mode', () => { + // const debounceInterval = 300; + // + // beforeEach(() => { + // keyManager.withTypeAhead(debounceInterval); + // keyManager.setActiveItem(-1); + // }); + // + // it('should throw if the items do not implement the getLabel method', () => { + // const invalidQueryList = new QueryList(); + // invalidQueryList.reset([{disabled: false}]); + // + // const invalidManager = new ListKeyManager( + // invalidQueryList as QueryList, + // ); + // + // expect(() => invalidManager.withTypeAhead()).toThrowError(/must implement/); + // }); + // + // it('should debounce the input key presses', fakeAsync(() => { + // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + // keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" + // keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" + // + // expect(keyManager.activeItem).not.toBe(itemList.toArray()[0]); + // + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should focus the first item that starts with a letter', fakeAsync(() => { + // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + // + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); + // })); + // + // it('should not move focus if a modifier, that is not allowed, is pressed', fakeAsync(() => { + // const tEvent = createKeyboardEvent('keydown', 84, 't', {control: true}); + // + // expect(keyManager.activeItem).toBeFalsy(); + // + // keyManager.onKeydown(tEvent); // types "t" + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBeFalsy(); + // })); + // + // it('should always allow the shift key', fakeAsync(() => { + // const tEvent = createKeyboardEvent('keydown', 84, 't', {shift: true}); + // + // expect(keyManager.activeItem).toBeFalsy(); + // + // keyManager.onKeydown(tEvent); // types "t" + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBeTruthy(); + // })); + // + // it('should focus the first item that starts with sequence of letters', fakeAsync(() => { + // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + // keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + // + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[2]); + // })); + // + // it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => { + // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + // keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should handle non-English input', fakeAsync(() => { + // itemList.reset([ + // new FakeFocusable('едно'), + // new FakeFocusable('две'), + // new FakeFocusable('три'), + // ]); + // + // const keyboardEvent = createKeyboardEvent('keydown', 68, 'д'); + // + // keyManager.onKeydown(keyboardEvent); // types "д" + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); + // })); + // + // it('should handle non-letter characters', fakeAsync(() => { + // itemList.reset([new FakeFocusable('[]'), new FakeFocusable('321'), new FakeFocusable('`!?')]); + // + // keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`" + // tick(debounceInterval); + // expect(keyManager.activeItem).toBe(itemList.toArray()[2]); + // + // keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3" + // tick(debounceInterval); + // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); + // + // keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "[" + // tick(debounceInterval); + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should not focus disabled items', fakeAsync(() => { + // expect(keyManager.activeItem).toBeFalsy(); + // + // const items = itemList.toArray(); + // items[0].disabled = true; + // itemList.reset(items); + // + // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBeFalsy(); + // })); + // + // it('should start looking for matches after the active item', fakeAsync(() => { + // itemList.reset([ + // new FakeFocusable('Bilbo'), + // new FakeFocusable('Frodo'), + // new FakeFocusable('Pippin'), + // new FakeFocusable('Boromir'), + // new FakeFocusable('Aragorn'), + // ]); + // + // keyManager.setActiveItem(1); + // keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[3]); + // })); + // + // it('should wrap back around if there were no matches after the active item', fakeAsync(() => { + // itemList.reset([ + // new FakeFocusable('Bilbo'), + // new FakeFocusable('Frodo'), + // new FakeFocusable('Pippin'), + // new FakeFocusable('Boromir'), + // new FakeFocusable('Aragorn'), + // ]); + // + // keyManager.setActiveItem(3); + // keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should wrap back around if the last item is active', fakeAsync(() => { + // keyManager.setActiveItem(2); + // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should be able to select the first item', fakeAsync(() => { + // keyManager.setActiveItem(-1); + // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); + // })); + // + // it('should not do anything if there is no match', fakeAsync(() => { + // keyManager.setActiveItem(1); + // keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w')); + // tick(debounceInterval); + // + // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); + // })); + // + // it('should expose whether the user is currently typing', fakeAsync(() => { + // expect(keyManager.isTyping()).toBe(false); + // + // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + // + // expect(keyManager.isTyping()).toBe(true); + // + // tick(debounceInterval); + // + // expect(keyManager.isTyping()).toBe(false); + // })); + // }); + // + // let keyManager: FocusKeyManager; + // + // beforeEach(() => { + // itemList.reset([new FakeFocusable(), new FakeFocusable(), new FakeFocusable()]); + // keyManager = new FocusKeyManager(itemList); + // + // // first item is already focused + // keyManager.setFirstItemActive(); + // + // spyOn(itemList.toArray()[0], 'focus'); + // spyOn(itemList.toArray()[1], 'focus'); + // spyOn(itemList.toArray()[2], 'focus'); + // }); + // + // it('should focus subsequent items when down arrow is pressed', () => { + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); + // expect(itemList.toArray()[2].focus).not.toHaveBeenCalled(); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); + // expect(itemList.toArray()[2].focus).toHaveBeenCalledTimes(1); + // }); + // + // it('should focus previous items when up arrow is pressed', () => { + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // + // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); + // + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // + // expect(itemList.toArray()[0].focus).toHaveBeenCalledTimes(1); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); + // }); + // + // it('should allow setting the focused item without calling focus', () => { + // expect(keyManager.activeItemIndex) + // .withContext(`Expected first item of the list to be active.`) + // .toBe(0); + // + // keyManager.updateActiveItem(1); + // expect(keyManager.activeItemIndex) + // .withContext(`Expected activeItemIndex to update after calling updateActiveItem().`) + // .toBe(1); + // expect(itemList.toArray()[1].focus).not.toHaveBeenCalledTimes(1); + // }); + // + // it('should be able to set the focus origin', () => { + // keyManager.setFocusOrigin('mouse'); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledWith('mouse'); + // + // keyManager.onKeydown(fakeKeyEvents.downArrow); + // expect(itemList.toArray()[2].focus).toHaveBeenCalledWith('mouse'); + // + // keyManager.setFocusOrigin('keyboard'); + // + // keyManager.onKeydown(fakeKeyEvents.upArrow); + // expect(itemList.toArray()[1].focus).toHaveBeenCalledWith('keyboard'); + // }); +}); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 222471883abd..76ca5b938663 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -27,7 +27,7 @@ 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; + isDisabled?: (() => boolean) | boolean; /** The user-facing label for this item. */ getLabel?(): string; @@ -36,13 +36,13 @@ export interface TreeKeyManagerItem { activate(): void; /** Retrieves the parent for this item. This is `null` if there is no parent. */ - getParent(): this | null; + getParent(): TreeKeyManagerItem | null; /** Retrieves the children for this item. */ - getChildren(): this[] | Observable; + getChildren(): TreeKeyManagerItem[] | Observable; /** Determines if the item is currently expanded. */ - isExpanded(): boolean; + isExpanded: (() => boolean) | boolean; /** Collapses the item, hiding its children. */ collapse(): void; @@ -112,12 +112,13 @@ export class TreeKeyManager { * 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) => !!item.isDisabled?.(); + 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: Observable | QueryList | T[]; + private _items: T[] = []; constructor({ items, @@ -140,19 +141,22 @@ export class TreeKeyManager { this._activationFollowsFocus = activationFollowsFocus; } - this._items = items; - // 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._updateActiveItemIndex(newItems.toArray()); + 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; } } @@ -162,6 +166,9 @@ export class TreeKeyManager { */ 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. @@ -172,6 +179,7 @@ export class TreeKeyManager { 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: @@ -211,8 +219,8 @@ export class TreeKeyManager { break; } - // Note that we return here, in order to avoid preventing the default action of - // non-navigational keys or resetting the buffer of pressed letters. + // NB: return here, in order to avoid preventing the default action of non-navigational + // keys or resetting the buffer of pressed letters. return; } @@ -237,30 +245,34 @@ export class TreeKeyManager { return this._activeItem; } - private _setActiveItem(index: number) { - this._getItems() - .pipe(take(1)) - .subscribe(items => { - // Clamp the index between 0 and the length of the list. - index = Math.min(Math.max(index, 0), items.length - 1); - const activeItem = 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; - } + 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._activeItem = activeItem ?? null; + this._activeItemIndex = index; - this._activeItem?.focus(); - if (this._activationFollowsFocus) { - this._activateCurrentItem(); - } - }); + this.change.next(this._activeItem); + this._activeItem?.focus(); + if (this._activationFollowsFocus) { + this._activateCurrentItem(); + } } private _updateActiveItemIndex(newItems: T[]) { @@ -273,30 +285,40 @@ export class TreeKeyManager { } } - private _getItems(): Observable { - return coerceObservable(this._items); - } - //// Navigational methods private _focusFirstItem() { - this._setActiveItem(0); + this._setActiveItem(this._findNextAvailableItemIndex(-1)); } private _focusLastItem() { - this._getItems() - .pipe(take(1)) - .subscribe(items => { - this._setActiveItem(items.length - 1); - }); + this._setActiveItem(this._findPreviousAvailableItemIndex(this._items.length)); } private _focusPreviousItem() { - this._setActiveItem(this._activeItemIndex - 1); + this._setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); } private _focusNextItem() { - this._setActiveItem(this._activeItemIndex + 1); + this._setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex)); + } + + private _findNextAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex + 1; i < this._items.length; i++) { + if (!this._isItemDisabled(this._items[i])) { + return i; + } + } + return startingIndex; + } + + private _findPreviousAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex - 1; i >= 0; i--) { + if (!this._isItemDisabled(this._items[i])) { + return i; + } + } + return startingIndex; } /** @@ -309,6 +331,19 @@ export class TreeKeyManager { */ private _expandCurrentItem() {} + private _isCurrentItemExpanded() { + if (!this._activeItem) { + return false; + } + return typeof this._activeItem.isExpanded === 'boolean' + ? this._activeItem.isExpanded + : this._activeItem.isExpanded(); + } + + private _isItemDisabled(item: T) { + 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. */ private _expandAllItemsAtCurrentItemLevel() {} @@ -316,4 +351,3 @@ export class TreeKeyManager { this._activeItem?.activate(); } } - From 3e64422073802744bbeeb263f54cb7ddee948fc5 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Tue, 18 Apr 2023 21:48:15 +0000 Subject: [PATCH 10/11] fix(cdk/tree): fix lint errors --- src/cdk/a11y/key-manager/tree-key-manager.spec.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts index 02c3e55edf35..179037260563 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -12,18 +12,16 @@ import { } from '@angular/cdk/keycodes'; import {createKeyboardEvent} from '../../testing/private'; import {QueryList} from '@angular/core'; -import {fakeAsync, tick} from '@angular/core/testing'; import {take} from 'rxjs/operators'; -import {FocusOrigin} from '../focus-monitor/focus-monitor'; import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; import {Observable, of as observableOf} from 'rxjs'; class FakeBaseTreeKeyManagerItem { - public _isExpanded = false; - public _parent: FakeBaseTreeKeyManagerItem | null = null; - public _children: FakeBaseTreeKeyManagerItem[] = []; + _isExpanded = false; + _parent: FakeBaseTreeKeyManagerItem | null = null; + _children: FakeBaseTreeKeyManagerItem[] = []; - public isDisabled?: boolean = false; + isDisabled?: boolean = false; constructor(private _label: string) {} @@ -73,7 +71,7 @@ interface ExpandCollapseKeyEventTestContext { collapseKeyEvent: KeyboardEvent; } -fdescribe('TreeKeyManager', () => { +describe('TreeKeyManager', () => { let fakeKeyEvents: { downArrow: KeyboardEvent; upArrow: KeyboardEvent; From 3eae54ada6eea2472e1deca4dc93727e7e44e3c1 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Tue, 18 Apr 2023 21:52:40 +0000 Subject: [PATCH 11/11] fix(cdk/a11y): fix lint errors pt2 --- .../a11y/key-manager/tree-key-manager.spec.ts | 542 ------------------ src/cdk/a11y/key-manager/tree-key-manager.ts | 21 +- 2 files changed, 2 insertions(+), 561 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts index 179037260563..a45feaedf4ee 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -66,11 +66,6 @@ interface ItemConstructorTestContext { | FakeObservableTreeKeyManagerItem; } -interface ExpandCollapseKeyEventTestContext { - expandKeyEvent: KeyboardEvent; - collapseKeyEvent: KeyboardEvent; -} - describe('TreeKeyManager', () => { let fakeKeyEvents: { downArrow: KeyboardEvent; @@ -114,10 +109,6 @@ describe('TreeKeyManager', () => { FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem >; - let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0 - let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1 - let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3 - beforeEach(() => { itemList = new QueryList(); const parent1 = new itemParam.constructor('parent1'); @@ -135,10 +126,6 @@ describe('TreeKeyManager', () => { parent2._children = [parent2Child1]; parent2Child1._parent = parent2; - parentItem = parent1; - childItem = parent1Child1; - childItemWithNoChildren = parent1Child2; - itemList.reset([ parent1, parent1Child1, @@ -445,533 +432,4 @@ describe('TreeKeyManager', () => { }); }); } - - // describe('programmatic focus', () => { - // it('should setActiveItem()', () => { - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.setActiveItem(1); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected activeItemIndex to be updated when setActiveItem() was called.`) - // .toBe(1); - // }); - // - // it('should be able to set the active item by reference', () => { - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.setActiveItem(itemList.toArray()[2]); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected activeItemIndex to be updated.`) - // .toBe(2); - // }); - // - // it('should be able to set the active item without emitting an event', () => { - // const spy = jasmine.createSpy('change spy'); - // const subscription = keyManager.change.subscribe(spy); - // - // expect(keyManager.activeItemIndex).toBe(0); - // - // keyManager.updateActiveItem(2); - // - // expect(keyManager.activeItemIndex).toBe(2); - // expect(spy).not.toHaveBeenCalled(); - // - // subscription.unsubscribe(); - // }); - // - // it('should expose the active item correctly', () => { - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(keyManager.activeItemIndex) - // .withContext('Expected active item to be the second option.') - // .toBe(1); - // expect(keyManager.activeItem) - // .withContext('Expected the active item to match the second option.') - // .toBe(itemList.toArray()[1]); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext('Expected active item to be the third option.') - // .toBe(2); - // expect(keyManager.activeItem) - // .withContext('Expected the active item ID to match the third option.') - // .toBe(itemList.toArray()[2]); - // }); - // - // it('should setFirstItemActive()', () => { - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected last item of the list to be active.`) - // .toBe(2); - // - // keyManager.setFirstItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setFirstItemActive() to set the active item to the first item.`) - // .toBe(0); - // }); - // - // it('should set the active item to the second item if the first one is disabled', () => { - // const items = itemList.toArray(); - // items[0].disabled = true; - // itemList.reset(items); - // - // keyManager.setFirstItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected the second item to be active if the first was disabled.`) - // .toBe(1); - // }); - // - // it('should setLastItemActive()', () => { - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.setLastItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setLastItemActive() to set the active item to the last item.`) - // .toBe(2); - // }); - // - // it('should set the active item to the second to last item if the last is disabled', () => { - // const items = itemList.toArray(); - // items[2].disabled = true; - // itemList.reset(items); - // - // keyManager.setLastItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected the second to last item to be active if the last was disabled.`) - // .toBe(1); - // }); - // - // it('should setNextItemActive()', () => { - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.setNextItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setNextItemActive() to set the active item to the next item.`) - // .toBe(1); - // }); - // - // it('should set the active item to the next enabled item if next is disabled', () => { - // const items = itemList.toArray(); - // items[1].disabled = true; - // itemList.reset(items); - // - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.setNextItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setNextItemActive() to only set enabled items as active.`) - // .toBe(2); - // }); - // - // it('should setPreviousItemActive()', () => { - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected second item of the list to be active.`) - // .toBe(1); - // - // keyManager.setPreviousItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setPreviousItemActive() to set the active item to the previous.`) - // .toBe(0); - // }); - // - // it('should skip disabled items when setPreviousItemActive() is called', () => { - // const items = itemList.toArray(); - // items[1].disabled = true; - // itemList.reset(items); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected third item of the list to be active.`) - // .toBe(2); - // - // keyManager.setPreviousItemActive(); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected setPreviousItemActive() to skip the disabled item.`) - // .toBe(0); - // }); - // - // it('should not emit an event if the item did not change', () => { - // const spy = jasmine.createSpy('change spy'); - // const subscription = keyManager.change.subscribe(spy); - // - // keyManager.setActiveItem(2); - // keyManager.setActiveItem(2); - // - // expect(spy).toHaveBeenCalledTimes(1); - // - // subscription.unsubscribe(); - // }); - // }); - // - // describe('wrap mode', () => { - // it('should return itself to allow chaining', () => { - // expect(keyManager.withWrap()) - // .withContext(`Expected withWrap() to return an instance of ListKeyManager.`) - // .toEqual(keyManager); - // }); - // - // it('should wrap focus when arrow keying past items while in wrap mode', () => { - // keyManager.withWrap(); - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(keyManager.activeItemIndex).withContext('Expected last item to be active.').toBe(2); - // - // // this down arrow moves down past the end of the list - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext('Expected active item to wrap to beginning.') - // .toBe(0); - // - // // this up arrow moves up past the beginning of the list - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // expect(keyManager.activeItemIndex) - // .withContext('Expected active item to wrap to end.') - // .toBe(2); - // }); - // - // it('should set last item active when up arrow is pressed if no active item', () => { - // keyManager.withWrap(); - // keyManager.setActiveItem(-1); - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // - // expect(keyManager.activeItemIndex) - // .withContext('Expected last item to be active on up arrow if no active item.') - // .toBe(2); - // expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); - // expect(keyManager.setActiveItem).toHaveBeenCalledWith(2); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(keyManager.activeItemIndex) - // .withContext('Expected active item to be 0 after wrapping back to beginning.') - // .toBe(0); - // expect(keyManager.setActiveItem).toHaveBeenCalledWith(0); - // }); - // - // // This test should pass if all items are disabled and the down arrow key got pressed. - // // If the test setup crashes or this test times out, this test can be considered as failed. - // it('should not get into an infinite loop if all items are disabled', () => { - // keyManager.withWrap(); - // keyManager.setActiveItem(0); - // const items = itemList.toArray(); - // items.forEach(item => (item.disabled = true)); - // itemList.reset(items); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // }); - // - // it('should be able to disable wrapping', () => { - // keyManager.withWrap(); - // keyManager.setFirstItemActive(); - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // - // expect(keyManager.activeItemIndex).toBe(itemList.length - 1); - // - // keyManager.withWrap(false); - // keyManager.setFirstItemActive(); - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // - // expect(keyManager.activeItemIndex).toBe(0); - // }); - // }); - // - // describe('skip predicate', () => { - // it('should skip disabled items by default', () => { - // const items = itemList.toArray(); - // items[1].disabled = true; - // itemList.reset(items); - // - // expect(keyManager.activeItemIndex).toBe(0); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(keyManager.activeItemIndex).toBe(2); - // }); - // - // it('should be able to skip items with a custom predicate', () => { - // keyManager.skipPredicate(item => item.skipItem); - // - // const items = itemList.toArray(); - // items[1].skipItem = true; - // itemList.reset(items); - // - // expect(keyManager.activeItemIndex).toBe(0); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(keyManager.activeItemIndex).toBe(2); - // }); - // }); - // - // describe('typeahead mode', () => { - // const debounceInterval = 300; - // - // beforeEach(() => { - // keyManager.withTypeAhead(debounceInterval); - // keyManager.setActiveItem(-1); - // }); - // - // it('should throw if the items do not implement the getLabel method', () => { - // const invalidQueryList = new QueryList(); - // invalidQueryList.reset([{disabled: false}]); - // - // const invalidManager = new ListKeyManager( - // invalidQueryList as QueryList, - // ); - // - // expect(() => invalidManager.withTypeAhead()).toThrowError(/must implement/); - // }); - // - // it('should debounce the input key presses', fakeAsync(() => { - // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" - // keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" - // keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" - // - // expect(keyManager.activeItem).not.toBe(itemList.toArray()[0]); - // - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should focus the first item that starts with a letter', fakeAsync(() => { - // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" - // - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); - // })); - // - // it('should not move focus if a modifier, that is not allowed, is pressed', fakeAsync(() => { - // const tEvent = createKeyboardEvent('keydown', 84, 't', {control: true}); - // - // expect(keyManager.activeItem).toBeFalsy(); - // - // keyManager.onKeydown(tEvent); // types "t" - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBeFalsy(); - // })); - // - // it('should always allow the shift key', fakeAsync(() => { - // const tEvent = createKeyboardEvent('keydown', 84, 't', {shift: true}); - // - // expect(keyManager.activeItem).toBeFalsy(); - // - // keyManager.onKeydown(tEvent); // types "t" - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBeTruthy(); - // })); - // - // it('should focus the first item that starts with sequence of letters', fakeAsync(() => { - // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" - // keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" - // - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[2]); - // })); - // - // it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => { - // keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" - // keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should handle non-English input', fakeAsync(() => { - // itemList.reset([ - // new FakeFocusable('едно'), - // new FakeFocusable('две'), - // new FakeFocusable('три'), - // ]); - // - // const keyboardEvent = createKeyboardEvent('keydown', 68, 'д'); - // - // keyManager.onKeydown(keyboardEvent); // types "д" - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); - // })); - // - // it('should handle non-letter characters', fakeAsync(() => { - // itemList.reset([new FakeFocusable('[]'), new FakeFocusable('321'), new FakeFocusable('`!?')]); - // - // keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`" - // tick(debounceInterval); - // expect(keyManager.activeItem).toBe(itemList.toArray()[2]); - // - // keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3" - // tick(debounceInterval); - // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); - // - // keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "[" - // tick(debounceInterval); - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should not focus disabled items', fakeAsync(() => { - // expect(keyManager.activeItem).toBeFalsy(); - // - // const items = itemList.toArray(); - // items[0].disabled = true; - // itemList.reset(items); - // - // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBeFalsy(); - // })); - // - // it('should start looking for matches after the active item', fakeAsync(() => { - // itemList.reset([ - // new FakeFocusable('Bilbo'), - // new FakeFocusable('Frodo'), - // new FakeFocusable('Pippin'), - // new FakeFocusable('Boromir'), - // new FakeFocusable('Aragorn'), - // ]); - // - // keyManager.setActiveItem(1); - // keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[3]); - // })); - // - // it('should wrap back around if there were no matches after the active item', fakeAsync(() => { - // itemList.reset([ - // new FakeFocusable('Bilbo'), - // new FakeFocusable('Frodo'), - // new FakeFocusable('Pippin'), - // new FakeFocusable('Boromir'), - // new FakeFocusable('Aragorn'), - // ]); - // - // keyManager.setActiveItem(3); - // keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should wrap back around if the last item is active', fakeAsync(() => { - // keyManager.setActiveItem(2); - // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should be able to select the first item', fakeAsync(() => { - // keyManager.setActiveItem(-1); - // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[0]); - // })); - // - // it('should not do anything if there is no match', fakeAsync(() => { - // keyManager.setActiveItem(1); - // keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w')); - // tick(debounceInterval); - // - // expect(keyManager.activeItem).toBe(itemList.toArray()[1]); - // })); - // - // it('should expose whether the user is currently typing', fakeAsync(() => { - // expect(keyManager.isTyping()).toBe(false); - // - // keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" - // - // expect(keyManager.isTyping()).toBe(true); - // - // tick(debounceInterval); - // - // expect(keyManager.isTyping()).toBe(false); - // })); - // }); - // - // let keyManager: FocusKeyManager; - // - // beforeEach(() => { - // itemList.reset([new FakeFocusable(), new FakeFocusable(), new FakeFocusable()]); - // keyManager = new FocusKeyManager(itemList); - // - // // first item is already focused - // keyManager.setFirstItemActive(); - // - // spyOn(itemList.toArray()[0], 'focus'); - // spyOn(itemList.toArray()[1], 'focus'); - // spyOn(itemList.toArray()[2], 'focus'); - // }); - // - // it('should focus subsequent items when down arrow is pressed', () => { - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); - // expect(itemList.toArray()[2].focus).not.toHaveBeenCalled(); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); - // expect(itemList.toArray()[2].focus).toHaveBeenCalledTimes(1); - // }); - // - // it('should focus previous items when up arrow is pressed', () => { - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // - // expect(itemList.toArray()[0].focus).not.toHaveBeenCalled(); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); - // - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // - // expect(itemList.toArray()[0].focus).toHaveBeenCalledTimes(1); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledTimes(1); - // }); - // - // it('should allow setting the focused item without calling focus', () => { - // expect(keyManager.activeItemIndex) - // .withContext(`Expected first item of the list to be active.`) - // .toBe(0); - // - // keyManager.updateActiveItem(1); - // expect(keyManager.activeItemIndex) - // .withContext(`Expected activeItemIndex to update after calling updateActiveItem().`) - // .toBe(1); - // expect(itemList.toArray()[1].focus).not.toHaveBeenCalledTimes(1); - // }); - // - // it('should be able to set the focus origin', () => { - // keyManager.setFocusOrigin('mouse'); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledWith('mouse'); - // - // keyManager.onKeydown(fakeKeyEvents.downArrow); - // expect(itemList.toArray()[2].focus).toHaveBeenCalledWith('mouse'); - // - // keyManager.setFocusOrigin('keyboard'); - // - // keyManager.onKeydown(fakeKeyEvents.upArrow); - // expect(itemList.toArray()[1].focus).toHaveBeenCalledWith('keyboard'); - // }); }); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 76ca5b938663..ef3592b2a180 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -7,19 +7,15 @@ */ import { - A, DOWN_ARROW, END, ENTER, HOME, LEFT_ARROW, - NINE, RIGHT_ARROW, SPACE, TAB, UP_ARROW, - Z, - ZERO, } from '@angular/cdk/keycodes'; import {QueryList} from '@angular/core'; import {isObservable, Observable, Subject} from 'rxjs'; @@ -305,7 +301,7 @@ export class TreeKeyManager { private _findNextAvailableItemIndex(startingIndex: number) { for (let i = startingIndex + 1; i < this._items.length; i++) { - if (!this._isItemDisabled(this._items[i])) { + if (!this._skipPredicateFn(this._items[i])) { return i; } } @@ -314,7 +310,7 @@ export class TreeKeyManager { private _findPreviousAvailableItemIndex(startingIndex: number) { for (let i = startingIndex - 1; i >= 0; i--) { - if (!this._isItemDisabled(this._items[i])) { + if (!this._skipPredicateFn(this._items[i])) { return i; } } @@ -331,19 +327,6 @@ export class TreeKeyManager { */ private _expandCurrentItem() {} - private _isCurrentItemExpanded() { - if (!this._activeItem) { - return false; - } - return typeof this._activeItem.isExpanded === 'boolean' - ? this._activeItem.isExpanded - : this._activeItem.isExpanded(); - } - - private _isItemDisabled(item: T) { - 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. */ private _expandAllItemsAtCurrentItemLevel() {}