Skip to content

Commit dde70a3

Browse files
authored
feat(cdk/tree): implement typeahead for TreeKeyManager (#27202)
* feat(cdk/a11y): implement typeahead (needs test) * feat(cdk/a11y): handle typeahead in keydown handler * feat(cdk/a11y): fix typeahead build errors * feat(cdk/a11y): add tests for typeahead * feat(cdk/a11y): add TreeKeyManager to public a11y API * fix(cdk/a11y): tree key manager build errors/weird merge * feat(cdk/a11y): fix api goldens * fix(cdk/a11y): fix tests
1 parent efbfeee commit dde70a3

File tree

4 files changed

+352
-27
lines changed

4 files changed

+352
-27
lines changed

src/cdk/a11y/key-manager/tree-key-manager.spec.ts

Lines changed: 230 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {QueryList} from '@angular/core';
1515
import {take} from 'rxjs/operators';
1616
import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager';
1717
import {Observable, of as observableOf, Subscription} from 'rxjs';
18+
import {fakeAsync, tick} from '@angular/core/testing';
1819

1920
class FakeBaseTreeKeyManagerItem {
2021
_isExpanded = false;
@@ -115,14 +116,19 @@ describe('TreeKeyManager', () => {
115116
FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem
116117
>;
117118

119+
let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0
120+
let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1
121+
let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3
122+
let lastItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 5
123+
118124
beforeEach(() => {
119125
itemList = new QueryList<FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem>();
120-
const parent1 = new itemParam.constructor('parent1');
121-
const parent1Child1 = new itemParam.constructor('parent1Child1');
122-
const parent1Child1Child1 = new itemParam.constructor('parent1Child1Child1');
123-
const parent1Child2 = new itemParam.constructor('parent1Child2');
124-
const parent2 = new itemParam.constructor('parent2');
125-
const parent2Child1 = new itemParam.constructor('parent2Child1');
126+
const parent1 = new itemParam.constructor('one');
127+
const parent1Child1 = new itemParam.constructor('two');
128+
const parent1Child1Child1 = new itemParam.constructor('three');
129+
const parent1Child2 = new itemParam.constructor('four');
130+
const parent2 = new itemParam.constructor('five');
131+
const parent2Child1 = new itemParam.constructor('six');
126132

127133
parent1._children = [parent1Child1, parent1Child2];
128134
parent1Child1._parent = parent1;
@@ -132,6 +138,11 @@ describe('TreeKeyManager', () => {
132138
parent2._children = [parent2Child1];
133139
parent2Child1._parent = parent2;
134140

141+
parentItem = parent1;
142+
childItem = parent1Child1;
143+
childItemWithNoChildren = parent1Child2;
144+
lastItem = parent2Child1;
145+
135146
itemList.reset([
136147
parent1,
137148
parent1Child1,
@@ -155,16 +166,12 @@ describe('TreeKeyManager', () => {
155166
keyManager.onClick(itemList.get(0)!);
156167

157168
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
158-
expect(keyManager.getActiveItem()?.getLabel())
159-
.withContext('active item label')
160-
.toBe('parent1');
169+
expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one');
161170
itemList.reset([new FakeObservableTreeKeyManagerItem('parent0'), ...itemList.toArray()]);
162171
itemList.notifyOnChanges();
163172

164173
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
165-
expect(keyManager.getActiveItem()?.getLabel())
166-
.withContext('active item label')
167-
.toBe('parent1');
174+
expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one');
168175
});
169176

170177
describe('Key events', () => {
@@ -728,6 +735,217 @@ describe('TreeKeyManager', () => {
728735
});
729736
}
730737
});
738+
739+
describe('typeahead mode', () => {
740+
const debounceInterval = 300;
741+
742+
beforeEach(() => {
743+
keyManager = new TreeKeyManager({
744+
items: itemList,
745+
typeAheadDebounceInterval: debounceInterval,
746+
});
747+
});
748+
749+
it('should throw if the items do not implement the getLabel method', () => {
750+
const invalidQueryList = new QueryList<any>();
751+
invalidQueryList.reset([{disabled: false}]);
752+
753+
expect(
754+
() =>
755+
new TreeKeyManager({
756+
items: invalidQueryList,
757+
typeAheadDebounceInterval: true,
758+
}),
759+
).toThrowError(/must implement/);
760+
});
761+
762+
it('should debounce the input key presses', fakeAsync(() => {
763+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
764+
tick(1);
765+
keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n"
766+
tick(1);
767+
keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e"
768+
769+
expect(keyManager.getActiveItemIndex())
770+
.withContext('active item index, before debounce interval')
771+
.not.toBe(0);
772+
773+
tick(debounceInterval - 1);
774+
775+
expect(keyManager.getActiveItemIndex())
776+
.withContext('active item index, after partial debounce interval')
777+
.not.toBe(0);
778+
779+
tick(1);
780+
781+
expect(keyManager.getActiveItemIndex())
782+
.withContext('active item index, after full debounce interval')
783+
.toBe(0);
784+
}));
785+
786+
it('uses a default debounce interval', fakeAsync(() => {
787+
const defaultInterval = 200;
788+
keyManager = new TreeKeyManager({
789+
items: itemList,
790+
typeAheadDebounceInterval: true,
791+
});
792+
793+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
794+
tick(1);
795+
keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n"
796+
tick(1);
797+
keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e"
798+
799+
expect(keyManager.getActiveItemIndex())
800+
.withContext('active item index, before debounce interval')
801+
.not.toBe(0);
802+
803+
tick(defaultInterval - 1);
804+
805+
expect(keyManager.getActiveItemIndex())
806+
.withContext('active item index, after partial debounce interval')
807+
.not.toBe(0);
808+
809+
tick(1);
810+
811+
expect(keyManager.getActiveItemIndex())
812+
.withContext('active item index, after full debounce interval')
813+
.toBe(0);
814+
}));
815+
816+
it('should focus the first item that starts with a letter', fakeAsync(() => {
817+
keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t"
818+
819+
tick(debounceInterval);
820+
821+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
822+
}));
823+
824+
it('should focus the first item that starts with sequence of letters', fakeAsync(() => {
825+
keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t"
826+
keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h"
827+
828+
tick(debounceInterval);
829+
830+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
831+
}));
832+
833+
it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => {
834+
keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t"
835+
keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h"
836+
keyManager.onKeydown(fakeKeyEvents.downArrow);
837+
838+
tick(debounceInterval);
839+
840+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
841+
}));
842+
843+
it('should handle non-English input', fakeAsync(() => {
844+
itemList.reset([
845+
new itemParam.constructor('едно'),
846+
new itemParam.constructor('две'),
847+
new itemParam.constructor('три'),
848+
]);
849+
itemList.notifyOnChanges();
850+
851+
const keyboardEvent = createKeyboardEvent('keydown', 68, 'д');
852+
853+
keyManager.onKeydown(keyboardEvent); // types "д"
854+
tick(debounceInterval);
855+
856+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
857+
}));
858+
859+
it('should handle non-letter characters', fakeAsync(() => {
860+
itemList.reset([
861+
new itemParam.constructor('[]'),
862+
new itemParam.constructor('321'),
863+
new itemParam.constructor('`!?'),
864+
]);
865+
itemList.notifyOnChanges();
866+
867+
keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`"
868+
tick(debounceInterval);
869+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);
870+
871+
keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3"
872+
tick(debounceInterval);
873+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
874+
875+
keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "["
876+
tick(debounceInterval);
877+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
878+
}));
879+
880+
it('should not focus disabled items', fakeAsync(() => {
881+
expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1);
882+
883+
parentItem.isDisabled = true;
884+
885+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
886+
tick(debounceInterval);
887+
888+
expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1);
889+
}));
890+
891+
it('should start looking for matches after the active item', fakeAsync(() => {
892+
const frodo = new itemParam.constructor('Frodo');
893+
itemList.reset([
894+
new itemParam.constructor('Bilbo'),
895+
frodo,
896+
new itemParam.constructor('Pippin'),
897+
new itemParam.constructor('Boromir'),
898+
new itemParam.constructor('Aragorn'),
899+
]);
900+
itemList.notifyOnChanges();
901+
902+
keyManager.onClick(frodo);
903+
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
904+
tick(debounceInterval);
905+
906+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(3);
907+
}));
908+
909+
it('should wrap back around if there were no matches after the active item', fakeAsync(() => {
910+
const boromir = new itemParam.constructor('Boromir');
911+
itemList.reset([
912+
new itemParam.constructor('Bilbo'),
913+
new itemParam.constructor('Frodo'),
914+
new itemParam.constructor('Pippin'),
915+
boromir,
916+
new itemParam.constructor('Aragorn'),
917+
]);
918+
itemList.notifyOnChanges();
919+
920+
keyManager.onClick(boromir);
921+
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
922+
tick(debounceInterval);
923+
924+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
925+
}));
926+
927+
it('should wrap back around if the last item is active', fakeAsync(() => {
928+
keyManager.onClick(lastItem);
929+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o'));
930+
tick(debounceInterval);
931+
932+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
933+
}));
934+
935+
it('should be able to select the first item', fakeAsync(() => {
936+
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o'));
937+
tick(debounceInterval);
938+
939+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
940+
}));
941+
942+
it('should not do anything if there is no match', fakeAsync(() => {
943+
keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w'));
944+
tick(debounceInterval);
945+
946+
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1);
947+
}));
948+
});
731949
});
732950
}
733951
});

0 commit comments

Comments
 (0)