Skip to content

Commit 83b369e

Browse files
crisbetojelbourn
authored andcommitted
fix(select): support changing the value using left/right arrow keys while closed (#9578)
Based on the native `<select>`, adds the ability for users to change the value on a closed select using the left/right arrow keys.
1 parent d0011c4 commit 83b369e

File tree

2 files changed

+114
-20
lines changed

2 files changed

+114
-20
lines changed

src/lib/select/select.spec.ts

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import {Directionality} from '@angular/cdk/bidi';
2-
import {DOWN_ARROW, END, ENTER, HOME, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes';
2+
import {
3+
DOWN_ARROW,
4+
END,
5+
ENTER,
6+
HOME,
7+
SPACE,
8+
TAB,
9+
UP_ARROW,
10+
LEFT_ARROW,
11+
RIGHT_ARROW,
12+
} from '@angular/cdk/keycodes';
313
import {OverlayContainer} from '@angular/cdk/overlay';
414
import {Platform} from '@angular/cdk/platform';
515
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
@@ -219,7 +229,7 @@ describe('MatSelect', () => {
219229
expect(select.getAttribute('tabindex')).toEqual('0');
220230
}));
221231

222-
it('should select options via the arrow keys on a closed select', fakeAsync(() => {
232+
it('should select options via the UP/DOWN arrow keys on a closed select', fakeAsync(() => {
223233
const formControl = fixture.componentInstance.control;
224234
const options = fixture.componentInstance.options.toArray();
225235

@@ -246,6 +256,33 @@ describe('MatSelect', () => {
246256
'Expected value from second option to have been set on the model.');
247257
}));
248258

259+
it('should select options via LEFT/RIGHT arrow keys on a closed select', fakeAsync(() => {
260+
const formControl = fixture.componentInstance.control;
261+
const options = fixture.componentInstance.options.toArray();
262+
263+
expect(formControl.value).toBeFalsy('Expected no initial value.');
264+
265+
dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW);
266+
267+
expect(options[0].selected).toBe(true, 'Expected first option to be selected.');
268+
expect(formControl.value).toBe(options[0].value,
269+
'Expected value from first option to have been set on the model.');
270+
271+
dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW);
272+
dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW);
273+
274+
// Note that the third option is skipped, because it is disabled.
275+
expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.');
276+
expect(formControl.value).toBe(options[3].value,
277+
'Expected value from fourth option to have been set on the model.');
278+
279+
dispatchKeyboardEvent(select, 'keydown', LEFT_ARROW);
280+
281+
expect(options[1].selected).toBe(true, 'Expected second option to be selected.');
282+
expect(formControl.value).toBe(options[1].value,
283+
'Expected value from second option to have been set on the model.');
284+
}));
285+
249286
it('should open a single-selection select using ALT + DOWN_ARROW', fakeAsync(() => {
250287
const {control: formControl, select: selectInstance} = fixture.componentInstance;
251288

@@ -331,26 +368,47 @@ describe('MatSelect', () => {
331368
'Expected value from sixth option to have been set on the model.');
332369
}));
333370

334-
it('should open the panel when pressing the arrow keys on a closed multiple select',
335-
fakeAsync(() => {
336-
fixture.destroy();
371+
it('should open the panel when pressing a vertical arrow key on a closed multiple select',
372+
fakeAsync(() => {
373+
fixture.destroy();
337374

338-
const multiFixture = TestBed.createComponent(MultiSelect);
339-
const instance = multiFixture.componentInstance;
375+
const multiFixture = TestBed.createComponent(MultiSelect);
376+
const instance = multiFixture.componentInstance;
340377

341-
multiFixture.detectChanges();
342-
select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement;
378+
multiFixture.detectChanges();
379+
select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement;
343380

344-
const initialValue = instance.control.value;
381+
const initialValue = instance.control.value;
345382

346-
expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.');
383+
expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.');
347384

348-
const event = dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
385+
const event = dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
349386

350-
expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.');
351-
expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.');
352-
expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.');
353-
}));
387+
expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.');
388+
expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.');
389+
expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.');
390+
}));
391+
392+
it('should open the panel when pressing a horizontal arrow key on closed multiple select',
393+
fakeAsync(() => {
394+
fixture.destroy();
395+
396+
const multiFixture = TestBed.createComponent(MultiSelect);
397+
const instance = multiFixture.componentInstance;
398+
399+
multiFixture.detectChanges();
400+
select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement;
401+
402+
const initialValue = instance.control.value;
403+
404+
expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.');
405+
406+
const event = dispatchKeyboardEvent(select, 'keydown', RIGHT_ARROW);
407+
408+
expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.');
409+
expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.');
410+
expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.');
411+
}));
354412

355413
it('should do nothing when typing on a closed multi-select', fakeAsync(() => {
356414
fixture.destroy();
@@ -623,6 +681,26 @@ describe('MatSelect', () => {
623681
expect(host.getAttribute('aria-activedescendant')).toBe(options[3].id);
624682
}));
625683

684+
it('should not change the aria-activedescendant using the horizontal arrow keys',
685+
fakeAsync(() => {
686+
const host = fixture.debugElement.query(By.css('mat-select')).nativeElement;
687+
688+
fixture.componentInstance.select.open();
689+
fixture.detectChanges();
690+
flush();
691+
692+
const options = overlayContainerElement.querySelectorAll('mat-option');
693+
694+
expect(host.getAttribute('aria-activedescendant')).toBe(options[0].id);
695+
696+
[1, 2, 3].forEach(() => {
697+
dispatchKeyboardEvent(host, 'keydown', RIGHT_ARROW);
698+
fixture.detectChanges();
699+
});
700+
701+
expect(host.getAttribute('aria-activedescendant')).toBe(options[0].id);
702+
}));
703+
626704
});
627705

628706
describe('for options', () => {

src/lib/select/select.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
99
import {Directionality} from '@angular/cdk/bidi';
1010
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1111
import {SelectionModel} from '@angular/cdk/collections';
12-
import {DOWN_ARROW, END, ENTER, HOME, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
12+
import {
13+
DOWN_ARROW,
14+
END,
15+
ENTER,
16+
HOME,
17+
SPACE,
18+
UP_ARROW,
19+
LEFT_ARROW,
20+
RIGHT_ARROW,
21+
} from '@angular/cdk/keycodes';
1322
import {
1423
CdkConnectedOverlay,
1524
Overlay,
@@ -535,6 +544,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
535544
this._triggerFontSize = parseInt(getComputedStyle(this.trigger.nativeElement)['font-size']);
536545

537546
this._panelOpen = true;
547+
this._keyManager.withHorizontalOrientation(null);
538548
this._calculateOverlayPosition();
539549
this._highlightCorrectOption();
540550
this._changeDetectorRef.markForCheck();
@@ -552,6 +562,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
552562
close(): void {
553563
if (this._panelOpen) {
554564
this._panelOpen = false;
565+
this._keyManager.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr');
555566
this._changeDetectorRef.markForCheck();
556567
this._onTouched();
557568
}
@@ -648,7 +659,8 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
648659
/** Handles keyboard events while the select is closed. */
649660
private _handleClosedKeydown(event: KeyboardEvent): void {
650661
const keyCode = event.keyCode;
651-
const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW;
662+
const isArrowKey = keyCode === DOWN_ARROW || keyCode === UP_ARROW ||
663+
keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW;
652664
const isOpenKey = keyCode === ENTER || keyCode === SPACE;
653665

654666
// Open the select on ALT + arrow key to match the native <select>
@@ -835,8 +847,12 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
835847

836848
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
837849
private _initKeyManager() {
838-
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options).withTypeAhead();
839-
this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => this.close());
850+
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options)
851+
.withTypeAhead()
852+
.withVerticalOrientation()
853+
.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr');
854+
855+
this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => this.close());
840856
this._keyManager.change.pipe(takeUntil(this._destroy)).subscribe(() => {
841857
if (this._panelOpen && this.panel) {
842858
this._scrollActiveOptionIntoView();

0 commit comments

Comments
 (0)