From a518536c6e2a943c248228c5fb739a9b2cd7ae12 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 5 Sep 2017 22:26:25 +0200 Subject: [PATCH] refactor(select): use aria-activedescendant to manage focus * Refactors the select to use `aria-activedescendant` to announce the highlighted item to screen readers. Previously we would this through focus, however using focus prevents us from being able to do things like #3211. * Fixes a hack that was used to get a hold of the panel element using `querySelector`. Now it properly uses a `ViewChild` query, however this meant some tests had to be updated. Relates to #3211. Fixes #6690. --- src/lib/select/select.html | 7 +- src/lib/select/select.spec.ts | 524 +++++++++++++++++++++++----------- src/lib/select/select.ts | 145 ++++++---- 3 files changed, 460 insertions(+), 216 deletions(-) diff --git a/src/lib/select/select.html b/src/lib/select/select.html index 9379f0980a2e..9865b2915bc4 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -34,16 +34,19 @@ (detach)="close()">
-
+
diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index b894b50a1a3c..caae9e7dd300 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -43,6 +43,12 @@ import { getMdSelectNonFunctionValueError } from './select-errors'; +/** Duration of the select opening animation. */ +const SELECT_OPEN_ANIMATION = 200; + +/** Duration of the select closing animation and the timeout interval for the backdrop. */ +const SELECT_CLOSE_ANIMATION = 500; + describe('MdSelect', () => { let overlayContainerElement: HTMLElement; @@ -153,48 +159,48 @@ describe('MdSelect', () => { beforeEach(() => { fixture = TestBed.createComponent(BasicSelect); fixture.detectChanges(); - trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; }); - it('should open the panel when trigger is clicked', () => { + it('should open the panel when trigger is clicked', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); expect(fixture.componentInstance.select.panelOpen).toBe(true); expect(overlayContainerElement.textContent).toContain('Steak'); expect(overlayContainerElement.textContent).toContain('Pizza'); expect(overlayContainerElement.textContent).toContain('Tacos'); - }); + })); - it('should close the panel when an item is clicked', async(() => { + it('should close the panel when an item is clicked', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).toEqual(''); - expect(fixture.componentInstance.select.panelOpen).toBe(false); - }); + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); })); - it('should close the panel when a click occurs outside the panel', async(() => { + it('should close the panel when a click occurs outside the panel', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).toEqual(''); - expect(fixture.componentInstance.select.panelOpen).toBe(false); - }); + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); })); it('should set the width of the overlay based on the trigger', async(() => { @@ -252,47 +258,50 @@ describe('MdSelect', () => { }); })); - it('should close the panel when tabbing out', async(() => { + it('should close the panel when tabbing out', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); expect(fixture.componentInstance.select.panelOpen).toBe(true); - const panel = overlayContainerElement.querySelector('.mat-select-panel')!; - dispatchKeyboardEvent(panel, 'keydown', TAB); + dispatchKeyboardEvent(trigger, 'keydown', TAB); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - expect(fixture.componentInstance.select.panelOpen).toBe(false); - }); + expect(fixture.componentInstance.select.panelOpen).toBe(false); })); - it('should focus the first option when pressing HOME', () => { + it('should focus the first option when pressing HOME', fakeAsync(() => { fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); - const panel = overlayContainerElement.querySelector('.mat-select-panel')!; - const event = dispatchKeyboardEvent(panel, 'keydown', HOME); + const event = dispatchKeyboardEvent(trigger, 'keydown', HOME); + fixture.detectChanges(); + tick(); expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); expect(event.defaultPrevented).toBe(true); - }); + })); - it('should focus the last option when pressing END', () => { + it('should focus the last option when pressing END', fakeAsync(() => { fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); - const panel = overlayContainerElement.querySelector('.mat-select-panel')!; - const event = dispatchKeyboardEvent(panel, 'keydown', END); + const event = dispatchKeyboardEvent(trigger, 'keydown', END); + fixture.detectChanges(); + tick(); expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7); expect(event.defaultPrevented).toBe(true); - }); + })); it('should be able to set extra classes on the panel', () => { trigger.click(); @@ -346,7 +355,6 @@ describe('MdSelect', () => { beforeEach(() => { fixture = TestBed.createComponent(BasicSelect); fixture.detectChanges(); - trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; }); @@ -356,25 +364,27 @@ describe('MdSelect', () => { .toBe(false, 'placeholder should not be floating'); }); - it('should focus the first option if no option is selected', async(() => { + it('should focus the first option if no option is selected', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); - fixture.whenStable().then(() => { - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0); - }); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0); })); - it('should select an option when it is clicked', () => { + it('should select an option when it is clicked', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); let option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); option = overlayContainerElement.querySelector('md-option') as HTMLElement; @@ -382,20 +392,23 @@ describe('MdSelect', () => { expect(fixture.componentInstance.options.first.selected).toBe(true); expect(fixture.componentInstance.select.selected) .toBe(fixture.componentInstance.options.first); - }); + })); - it('should deselect other options when one is selected', () => { + it('should deselect other options when one is selected', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); let options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; options[0].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; @@ -405,29 +418,31 @@ describe('MdSelect', () => { const optionInstances = fixture.componentInstance.options.toArray(); expect(optionInstances[1].selected).toBe(false); expect(optionInstances[2].selected).toBe(false); - }); + })); - it('should deselect other options when one is programmatically selected', () => { + it('should deselect other options when one is programmatically selected', fakeAsync(() => { let control = fixture.componentInstance.control; let foods = fixture.componentInstance.foods; trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); let options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; options[0].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); control.setValue(foods[1].value); fixture.detectChanges(); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); - options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; expect(options[0].classList) .not.toContain('mat-selected', 'Expected first option to no longer be selected'); @@ -440,7 +455,7 @@ describe('MdSelect', () => { .toBe(false, 'Expected first option to no longer be selected'); expect(optionInstances[1].selected) .toBe(true, 'Expected second option to be selected'); - }); + })); it('should remove selection if option has been removed', async(() => { let select = fixture.componentInstance.select; @@ -448,68 +463,73 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); - let firstOption = overlayContainerElement.querySelectorAll('md-option')[0] as HTMLElement; + fixture.whenStable().then(() => { + let firstOption = overlayContainerElement.querySelectorAll('md-option')[0] as HTMLElement; - firstOption.click(); - fixture.detectChanges(); + firstOption.click(); + fixture.detectChanges(); - expect(select.selected).toBe(select.options.first, 'Expected first option to be selected.'); + expect(select.selected).toBe(select.options.first, 'Expected first option to be selected.'); - fixture.componentInstance.foods = []; - fixture.detectChanges(); + fixture.componentInstance.foods = []; + fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(select.selected) - .toBeUndefined('Expected selection to be removed when option no longer exists.'); + fixture.whenStable().then(() => { + expect(select.selected) + .toBeUndefined('Expected selection to be removed when option no longer exists.'); + }); }); })); - it('should display the selected option in the trigger', () => { + it('should display the selected option in the trigger', fakeAsync(() => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); const value = fixture.debugElement.query(By.css('.mat-select-value')).nativeElement; expect(formField.classList.contains('mat-form-field-should-float')) .toBe(true, 'placeholder should be floating'); expect(value.textContent).toContain('Steak'); - }); + })); - it('should focus the selected option if an option is selected', async(() => { + it('should focus the selected option if an option is selected', fakeAsync(() => { // must wait for initial writeValue promise to finish - fixture.whenStable().then(() => { - fixture.componentInstance.control.setValue('pizza-1'); - fixture.detectChanges(); + tick(); - trigger.click(); - fixture.detectChanges(); + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); - // must wait for animation to finish - fixture.whenStable().then(() => { - fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(1); - }); - }); + trigger.click(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + + // must wait for animation to finish + fixture.detectChanges(); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(1); })); - it('should select an option that was added after initialization', () => { + it('should select an option that was added after initialization', fakeAsync(() => { fixture.componentInstance.foods.push({viewValue: 'Potatoes', value: 'potatoes-8'}); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; options[8].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(trigger.textContent).toContain('Potatoes'); expect(fixture.componentInstance.select.selected) .toBe(fixture.componentInstance.options.last); - }); + })); it('should not select disabled options', () => { trigger.click(); @@ -556,7 +576,7 @@ describe('MdSelect', () => { trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; }); - it('should take an initial view value with reactive forms', () => { + it('should take an initial view value with reactive forms', fakeAsync(() => { fixture.componentInstance.control = new FormControl('pizza-1'); fixture.detectChanges(); @@ -567,15 +587,16 @@ describe('MdSelect', () => { trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; expect(options[1].classList) .toContain('mat-selected', `Expected option with the control's initial value to be selected.`); - }); + })); - it('should set the view value from the form', () => { + it('should set the view value from the form', fakeAsync(() => { let value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent.trim()).toBe(''); @@ -588,29 +609,32 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; - expect(options[1].classList).toContain('mat-selected', - `Expected option with the control's new value to be selected.`); - }); + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[1].classList) + .toContain('mat-selected', `Expected option with the control's new value to be selected.`); + })); - it('should update the form value when the view changes', () => { + it('should update the form value when the view changes', fakeAsync(() => { expect(fixture.componentInstance.control.value) .toEqual(null, `Expected the control's value to be empty initially.`); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.value) .toEqual('steak-0', `Expected control's value to be set to the new option.`); - }); + })); - it('should clear the selection when a nonexistent option value is selected', () => { + it('should clear the selection when a nonexistent option value is selected', fakeAsync(() => { fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); @@ -625,15 +649,16 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; expect(options[1].classList) .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); - }); + })); - it('should clear the selection when the control is reset', () => { + it('should clear the selection when the control is reset', fakeAsync(() => { fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); @@ -648,20 +673,23 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; expect(options[1].classList) .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); - }); + })); - it('should set the control to touched when the select is touched', () => { + it('should set the control to touched when the select is touched', fakeAsync(() => { expect(fixture.componentInstance.control.touched) .toEqual(false, `Expected the control to start off as untouched.`); trigger.click(); dispatchFakeEvent(trigger, 'blur'); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + expect(fixture.componentInstance.control.touched) .toEqual(false, `Expected the control to stay untouched when menu opened.`); @@ -670,9 +698,11 @@ describe('MdSelect', () => { backdrop.click(); dispatchFakeEvent(trigger, 'blur'); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); + expect(fixture.componentInstance.control.touched) .toEqual(true, `Expected the control to be touched as soon as focus left the select.`); - }); + })); it('should not set touched when a disabled select is touched', () => { expect(fixture.componentInstance.control.touched) @@ -685,20 +715,22 @@ describe('MdSelect', () => { .toBe(false, 'Expected the control to stay untouched.'); }); - it('should set the control to dirty when the select\'s value changes in the DOM', () => { + it('should set the control to dirty when the select value changes in the DOM', fakeAsync(() => { expect(fixture.componentInstance.control.dirty) .toEqual(false, `Expected control to start out pristine.`); trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); const option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.dirty) .toEqual(true, `Expected control to be dirty after value was changed by user.`); - }); + })); it('should not set the control to dirty when the value changes programmatically', () => { expect(fixture.componentInstance.control.dirty) @@ -724,7 +756,7 @@ describe('MdSelect', () => { .not.toBeNull(`Expected placeholder to have an asterisk, as control was required.`); }); - it('should be able to programmatically select a falsy option', () => { + it('should be able to programmatically select a falsy option', fakeAsync(() => { fixture.destroy(); const falsyFixture = TestBed.createComponent(FalsyValueSelect); @@ -733,17 +765,18 @@ describe('MdSelect', () => { falsyFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement.click(); falsyFixture.componentInstance.control.setValue(0); falsyFixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); expect(falsyFixture.componentInstance.options.first.selected) .toBe(true, 'Expected first option to be selected'); expect(overlayContainerElement.querySelectorAll('md-option')[0].classList) .toContain('mat-selected', 'Expected first option to be selected'); - }); + })); }); describe('selection without Angular forms', () => { - it('should set the value when options are clicked', () => { + it('should set the value when options are clicked', fakeAsync(() => { const fixture = TestBed.createComponent(BasicSelectWithoutForms); fixture.detectChanges(); @@ -753,9 +786,11 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); (overlayContainerElement.querySelector('md-option') as HTMLElement).click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.selectedFood).toBe('steak-0'); expect(fixture.componentInstance.select.value).toBe('steak-0'); @@ -763,14 +798,16 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); (overlayContainerElement.querySelectorAll('md-option')[2] as HTMLElement).click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.selectedFood).toBe('sandwich-2'); expect(fixture.componentInstance.select.value).toBe('sandwich-2'); expect(trigger.textContent).toContain('Sandwich'); - }); + })); it('should mark options as selected when the value is set', () => { const fixture = TestBed.createComponent(BasicSelectWithoutForms); @@ -791,7 +828,7 @@ describe('MdSelect', () => { expect(fixture.componentInstance.select.value).toBe('sandwich-2'); }); - it('should reset the placeholder when a null value is set', () => { + it('should reset the placeholder when a null value is set', fakeAsync(() => { const fixture = TestBed.createComponent(BasicSelectWithoutForms); fixture.detectChanges(); @@ -801,9 +838,11 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); (overlayContainerElement.querySelector('md-option') as HTMLElement).click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.selectedFood).toBe('steak-0'); expect(fixture.componentInstance.select.value).toBe('steak-0'); @@ -814,7 +853,7 @@ describe('MdSelect', () => { expect(fixture.componentInstance.select.value).toBeNull(); expect(trigger.textContent).not.toContain('Steak'); - }); + })); it('should reflect the preselected value', async(() => { const fixture = TestBed.createComponent(BasicSelectWithoutFormsPreselected); @@ -976,23 +1015,25 @@ describe('MdSelect', () => { formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; })); - it('should float the placeholder when the panel is open and unselected', () => { + it('should float the placeholder when the panel is open and unselected', fakeAsync(() => { expect(formField.classList.contains('mat-form-field-should-float')) .toBe(false, 'Expected placeholder to initially have a normal position.'); - trigger.click(); + fixture.componentInstance.select.open(); + tick(); fixture.detectChanges(); - expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(true, 'Expected placeholder to animate up to floating position.'); + tick(SELECT_OPEN_ANIMATION); - const backdrop = - overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; - backdrop.click(); + expect(formField.classList).toContain('mat-form-field-should-float', + 'Expected placeholder to animate up to floating position.'); + + fixture.componentInstance.select.close(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(false, 'Expected placeholder to animate back down to normal position.'); - }); + expect(formField.classList).not.toContain('mat-form-field-should-float', + 'Expected placeholder to animate back down to normal position.'); + })); it('should add a class to the panel when the menu is done animating', fakeAsync(() => { trigger.click(); @@ -1405,26 +1446,26 @@ describe('MdSelect', () => { `Expected select panel to be inside the viewport in rtl.`); })); - it('should keep the position within the viewport on repeat openings', async(() => { + it('should keep the position within the viewport on repeat openings', fakeAsync(() => { formField.style.left = '-100px'; trigger.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); let panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; - expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport.`); fixture.componentInstance.select.close(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - trigger.click(); - fixture.detectChanges(); - panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; + trigger.click(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; - expect(panelLeft).toBeGreaterThan(0, - `Expected select panel continue being inside the viewport.`); - }); + expect(panelLeft).toBeGreaterThan(0, + `Expected select panel continue being inside the viewport.`); })); }); @@ -1848,20 +1889,24 @@ describe('MdSelect', () => { expect(select.getAttribute('tabindex')).toEqual('0'); }); - it('should be able to select options via the arrow keys on a closed select', () => { + it('should be able to select options via the arrow keys on a closed select', fakeAsync(() => { const formControl = fixture.componentInstance.control; const options = fixture.componentInstance.options.toArray(); expect(formControl.value).toBeFalsy('Expected no initial value.'); dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); expect(options[0].selected).toBe(true, 'Expected first option to be selected.'); expect(formControl.value).toBe(options[0].value, 'Expected value from first option to have been set on the model.'); dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); // Note that the third option is skipped, because it is disabled. expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.'); @@ -1869,11 +1914,12 @@ describe('MdSelect', () => { 'Expected value from fourth option to have been set on the model.'); dispatchKeyboardEvent(select, 'keydown', UP_ARROW); + tick(); expect(options[1].selected).toBe(true, 'Expected second option to be selected.'); expect(formControl.value).toBe(options[1].value, 'Expected value from second option to have been set on the model.'); - }); + })); it('should open the panel when pressing the arrow keys on a closed multiple select', () => { fixture.destroy(); @@ -1895,29 +1941,33 @@ describe('MdSelect', () => { expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.'); }); - it('should do nothing if the key manager did not change the active item', () => { + it('should do nothing if the key manager did not change the active item', fakeAsync(() => { const formControl = fixture.componentInstance.control; expect(formControl.value).toBeNull('Expected form control value to be empty.'); expect(formControl.pristine).toBe(true, 'Expected form control to be clean.'); dispatchKeyboardEvent(select, 'keydown', 16); // Press a random key. + tick(); expect(formControl.value).toBeNull('Expected form control value to stay empty.'); expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.'); - }); + })); - it('should continue from the selected option when the value is set programmatically', () => { - const formControl = fixture.componentInstance.control; + it('should continue from the selected option when the value is set programmatically', + fakeAsync(() => { + const formControl = fixture.componentInstance.control; - formControl.setValue('eggs-5'); - fixture.detectChanges(); + formControl.setValue('eggs-5'); + fixture.detectChanges(); + tick(); - dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); - expect(formControl.value).toBe('pasta-6'); - expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true); - }); + expect(formControl.value).toBe('pasta-6'); + expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true); + })); it('should not shift focus when the selected options are updated programmatically ' + 'in a multi select', () => { @@ -1943,29 +1993,33 @@ describe('MdSelect', () => { .toBe(options[3], 'Expected fourth option to remain focused.'); }); - it('should not cycle through the options if the control is disabled', () => { + it('should not cycle through the options if the control is disabled', fakeAsync(() => { const formControl = fixture.componentInstance.control; formControl.setValue('eggs-5'); formControl.disable(); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.'); - }); + })); - it('should not wrap selection around after reaching the end of the options', () => { + it('should not wrap selection around after reaching the end of the options', fakeAsync(() => { const lastOption = fixture.componentInstance.options.last; fixture.componentInstance.options.forEach(() => { dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); }); expect(lastOption.selected).toBe(true, 'Expected last option to be selected.'); dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.'); - }); + })); it('should not open a multiple select when tabbing through', () => { fixture.destroy(); @@ -1985,21 +2039,21 @@ describe('MdSelect', () => { }); it('should prevent the default action when pressing space', () => { - let event = dispatchKeyboardEvent(select, 'keydown', SPACE); - + const event = dispatchKeyboardEvent(select, 'keydown', SPACE); expect(event.defaultPrevented).toBe(true); }); - it('should consider the selection as a result of a user action when closed', () => { + it('should consider the selection a result of a user action when closed', fakeAsync(() => { const option = fixture.componentInstance.options.first; const spy = jasmine.createSpy('option selection spy'); const subscription = map.call(option.onSelectionChange, e => e.isUserInput).subscribe(spy); dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + tick(); expect(spy).toHaveBeenCalledWith(true); subscription.unsubscribe(); - }); + })); it('should be able to focus the select trigger', () => { document.body.focus(); // ensure that focus isn't on the trigger already @@ -2033,6 +2087,60 @@ describe('MdSelect', () => { expect(select.getAttribute('aria-multiselectable')).toBe('false'); }); + it('should set aria-activedescendant only while the panel is open', fakeAsync(() => { + fixture.componentInstance.control.setValue('chips-4'); + fixture.detectChanges(); + + const host = fixture.debugElement.query(By.css('md-select')).nativeElement; + + expect(host.hasAttribute('aria-activedescendant')) + .toBe(false, 'Expected no aria-activedescendant on init.'); + + fixture.componentInstance.select.open(); + tick(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + + const options = overlayContainerElement.querySelectorAll('md-option'); + + expect(host.getAttribute('aria-activedescendant')) + .toBe(options[4].id, 'Expected aria-activedescendant to match the active option.'); + + fixture.componentInstance.select.close(); + fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); + + expect(host.hasAttribute('aria-activedescendant')) + .toBe(false, 'Expected no aria-activedescendant when closed.'); + })); + + it('should set aria-activedescendant based on the focused option', fakeAsync(() => { + const host = fixture.debugElement.query(By.css('md-select')).nativeElement; + + fixture.componentInstance.select.open(); + tick(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + + const options = overlayContainerElement.querySelectorAll('md-option'); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[0].id); + + [1, 2, 3].forEach(() => { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + tick(); + fixture.detectChanges(); + }); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[4].id); + + dispatchKeyboardEvent(host, 'keydown', UP_ARROW); + tick(); + fixture.detectChanges(); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[3].id); + })); + }); describe('for options', () => { @@ -2132,19 +2240,20 @@ describe('MdSelect', () => { let triggers: DebugElement[]; let options: NodeListOf; - beforeEach(() => { + beforeEach(fakeAsync(() => { fixture = TestBed.createComponent(ManySelects); fixture.detectChanges(); triggers = fixture.debugElement.queryAll(By.css('.mat-select-trigger')); triggers[0].nativeElement.click(); fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; - }); + })); - it('should set aria-owns properly', async(() => { + it('should set aria-owns properly', fakeAsync(() => { const selects = fixture.debugElement.queryAll(By.css('md-select')); expect(selects[0].nativeElement.getAttribute('aria-owns')) @@ -2156,22 +2265,22 @@ describe('MdSelect', () => { overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - triggers[1].nativeElement.click(); + triggers[1].nativeElement.click(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); - fixture.detectChanges(); - options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; - expect(selects[1].nativeElement.getAttribute('aria-owns')) - .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); - expect(selects[1].nativeElement.getAttribute('aria-owns')) - .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); - }); + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(selects[1].nativeElement.getAttribute('aria-owns')) + .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); + expect(selects[1].nativeElement.getAttribute('aria-owns')) + .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); })); - it('should set the option id properly', async(() => { + it('should set the option id properly', fakeAsync(() => { let firstOptionID = options[0].id; expect(options[0].id) @@ -2182,19 +2291,18 @@ describe('MdSelect', () => { overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); - fixture.whenStable().then(() => { - triggers[1].nativeElement.click(); - - fixture.detectChanges(); - options = - overlayContainerElement.querySelectorAll('md-option') as NodeListOf; - expect(options[0].id) - .toContain('md-option', `Expected option ID to have the correct prefix.`); - expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`); - expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); - }); + triggers[1].nativeElement.click(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[0].id) + .toContain('md-option', `Expected option ID to have the correct prefix.`); + expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`); + expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); })); }); }); @@ -2651,51 +2759,54 @@ describe('MdSelect', () => { fixture.whenStable().then(() => { options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf; - options[0].click(); fixture.detectChanges(); }); })); - it('should reset when an option with an undefined value is selected', () => { + it('should reset when an option with an undefined value is selected', fakeAsync(() => { options[4].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.value).toBeUndefined(); expect(fixture.componentInstance.select.selected).toBeFalsy(); expect(formField.classList).not.toContain('mat-form-field-should-float'); expect(trigger.textContent).not.toContain('Undefined'); - }); + })); - it('should reset when an option with a null value is selected', () => { + it('should reset when an option with a null value is selected', fakeAsync(() => { options[5].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.value).toBeNull(); expect(fixture.componentInstance.select.selected).toBeFalsy(); expect(formField.classList).not.toContain('mat-form-field-should-float'); expect(trigger.textContent).not.toContain('Null'); - }); + })); - it('should reset when a blank option is selected', () => { + it('should reset when a blank option is selected', fakeAsync(() => { options[6].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.value).toBeUndefined(); expect(fixture.componentInstance.select.selected).toBeFalsy(); expect(formField.classList).not.toContain('mat-form-field-should-float'); expect(trigger.textContent).not.toContain('None'); - }); + })); - it('should not reset when any other falsy option is selected', () => { + it('should not reset when any other falsy option is selected', fakeAsync(() => { options[3].click(); fixture.detectChanges(); + tick(SELECT_CLOSE_ANIMATION); expect(fixture.componentInstance.control.value).toBe(false); expect(fixture.componentInstance.select.selected).toBeTruthy(); expect(formField.classList).toContain('mat-form-field-should-float'); expect(trigger.textContent).toContain('Falsy'); - }); + })); it('should not consider the reset values as selected when resetting the form control', () => { expect(formField.classList).toContain('mat-form-field-should-float'); @@ -2850,6 +2961,97 @@ describe('MdSelect', () => { }); }); }); + + describe('keyboard scrolling', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + let panel: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicSelect); + + fixture.componentInstance.foods = []; + + for (let i = 0; i < 30; i++) { + fixture.componentInstance.foods.push({value: `value-${i}`, viewValue: `Option ${i}`}); + } + + fixture.detectChanges(); + fixture.componentInstance.select.open(); + fixture.detectChanges(); + tick(SELECT_OPEN_ANIMATION); + + host = fixture.debugElement.query(By.css('md-select')).nativeElement; + panel = overlayContainerElement.querySelector('.mat-select-panel')! as HTMLElement; + })); + + it('should not scroll to options that are completely in the view', fakeAsync(() => { + const initialScrollPosition = panel.scrollTop; + + [1, 2, 3].forEach(() => { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + tick(); + fixture.detectChanges(); + }); + + expect(panel.scrollTop).toBe(initialScrollPosition, 'Expected scroll position not to change'); + })); + + it('should scroll down to the active option', fakeAsync(() => { + for (let i = 0; i < 15; i++) { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + tick(); + fixture.detectChanges(); + } + + //