Skip to content

Commit

Permalink
feat(cdk/tree): implement typeahead for TreeKeyManager (#27202)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
BobobUnicorn authored and andrewseguin committed Jun 14, 2023
1 parent 4a55d74 commit 5f2c7bc
Show file tree
Hide file tree
Showing 4 changed files with 352 additions and 27 deletions.
242 changes: 230 additions & 12 deletions src/cdk/a11y/key-manager/tree-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {QueryList} from '@angular/core';
import {take} from 'rxjs/operators';
import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager';
import {Observable, of as observableOf, Subscription} from 'rxjs';
import {fakeAsync, tick} from '@angular/core/testing';

class FakeBaseTreeKeyManagerItem {
_isExpanded = false;
Expand Down Expand Up @@ -115,14 +116,19 @@ describe('TreeKeyManager', () => {
FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem
>;

let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0
let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1
let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3
let lastItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 5

beforeEach(() => {
itemList = new QueryList<FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem>();
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');
const parent1 = new itemParam.constructor('one');
const parent1Child1 = new itemParam.constructor('two');
const parent1Child1Child1 = new itemParam.constructor('three');
const parent1Child2 = new itemParam.constructor('four');
const parent2 = new itemParam.constructor('five');
const parent2Child1 = new itemParam.constructor('six');

parent1._children = [parent1Child1, parent1Child2];
parent1Child1._parent = parent1;
Expand All @@ -132,6 +138,11 @@ describe('TreeKeyManager', () => {
parent2._children = [parent2Child1];
parent2Child1._parent = parent2;

parentItem = parent1;
childItem = parent1Child1;
childItemWithNoChildren = parent1Child2;
lastItem = parent2Child1;

itemList.reset([
parent1,
parent1Child1,
Expand All @@ -155,16 +166,12 @@ describe('TreeKeyManager', () => {
keyManager.onClick(itemList.get(0)!);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
expect(keyManager.getActiveItem()?.getLabel())
.withContext('active item label')
.toBe('parent1');
expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one');
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');
expect(keyManager.getActiveItem()?.getLabel()).withContext('active item label').toBe('one');
});

describe('Key events', () => {
Expand Down Expand Up @@ -728,6 +735,217 @@ describe('TreeKeyManager', () => {
});
}
});

describe('typeahead mode', () => {
const debounceInterval = 300;

beforeEach(() => {
keyManager = new TreeKeyManager({
items: itemList,
typeAheadDebounceInterval: debounceInterval,
});
});

it('should throw if the items do not implement the getLabel method', () => {
const invalidQueryList = new QueryList<any>();
invalidQueryList.reset([{disabled: false}]);

expect(
() =>
new TreeKeyManager({
items: invalidQueryList,
typeAheadDebounceInterval: true,
}),
).toThrowError(/must implement/);
});

it('should debounce the input key presses', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
tick(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n"
tick(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e"

expect(keyManager.getActiveItemIndex())
.withContext('active item index, before debounce interval')
.not.toBe(0);

tick(debounceInterval - 1);

expect(keyManager.getActiveItemIndex())
.withContext('active item index, after partial debounce interval')
.not.toBe(0);

tick(1);

expect(keyManager.getActiveItemIndex())
.withContext('active item index, after full debounce interval')
.toBe(0);
}));

it('uses a default debounce interval', fakeAsync(() => {
const defaultInterval = 200;
keyManager = new TreeKeyManager({
items: itemList,
typeAheadDebounceInterval: true,
});

keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
tick(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n"
tick(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e"

expect(keyManager.getActiveItemIndex())
.withContext('active item index, before debounce interval')
.not.toBe(0);

tick(defaultInterval - 1);

expect(keyManager.getActiveItemIndex())
.withContext('active item index, after partial debounce interval')
.not.toBe(0);

tick(1);

expect(keyManager.getActiveItemIndex())
.withContext('active item index, after full debounce interval')
.toBe(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.getActiveItemIndex()).withContext('active item index').toBe(1);
}));

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.getActiveItemIndex()).withContext('active item index').toBe(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.getActiveItemIndex()).withContext('active item index').toBe(0);
}));

it('should handle non-English input', fakeAsync(() => {
itemList.reset([
new itemParam.constructor('едно'),
new itemParam.constructor('две'),
new itemParam.constructor('три'),
]);
itemList.notifyOnChanges();

const keyboardEvent = createKeyboardEvent('keydown', 68, 'д');

keyManager.onKeydown(keyboardEvent); // types "д"
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);
}));

it('should handle non-letter characters', fakeAsync(() => {
itemList.reset([
new itemParam.constructor('[]'),
new itemParam.constructor('321'),
new itemParam.constructor('`!?'),
]);
itemList.notifyOnChanges();

keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`"
tick(debounceInterval);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2);

keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3"
tick(debounceInterval);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1);

keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "["
tick(debounceInterval);
expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
}));

it('should not focus disabled items', fakeAsync(() => {
expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1);

parentItem.isDisabled = true;

keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1);
}));

it('should start looking for matches after the active item', fakeAsync(() => {
const frodo = new itemParam.constructor('Frodo');
itemList.reset([
new itemParam.constructor('Bilbo'),
frodo,
new itemParam.constructor('Pippin'),
new itemParam.constructor('Boromir'),
new itemParam.constructor('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.onClick(frodo);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(3);
}));

it('should wrap back around if there were no matches after the active item', fakeAsync(() => {
const boromir = new itemParam.constructor('Boromir');
itemList.reset([
new itemParam.constructor('Bilbo'),
new itemParam.constructor('Frodo'),
new itemParam.constructor('Pippin'),
boromir,
new itemParam.constructor('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.onClick(boromir);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
}));

it('should wrap back around if the last item is active', fakeAsync(() => {
keyManager.onClick(lastItem);
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o'));
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
}));

it('should be able to select the first item', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o'));
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0);
}));

it('should not do anything if there is no match', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w'));
tick(debounceInterval);

expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1);
}));
});
});
}
});
Loading

0 comments on commit 5f2c7bc

Please sign in to comment.