diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index e1b0625bb95..985a25735d4 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -19,7 +19,15 @@ import { ViewChild, ViewChildren, } from '@angular/core'; -import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing'; +import { + async, + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick, +} from '@angular/core/testing'; import { ControlValueAccessor, FormControl, @@ -47,7 +55,7 @@ import {MatSelect} from './select'; import { getMatSelectDynamicMultipleError, getMatSelectNonArrayValueError, - getMatSelectNonFunctionValueError + getMatSelectNonFunctionValueError, } from './select-errors'; /** The debounce interval when typing letters to select an option. */ @@ -62,731 +70,2116 @@ describe('MatSelect', () => { let scrolledSubject = new Subject(); let viewportRuler: ViewportRuler; - beforeEach(fakeAsync(() => { + // Providers used for all mat-select tests + const commonProviders = [ + { + provide: OverlayContainer, useFactory: () => { + overlayContainerElement = document.createElement('div') as HTMLElement; + overlayContainerElement.classList.add('cdk-overlay-container'); + + document.body.appendChild(overlayContainerElement); + + // remove body padding to keep consistent cross-browser + document.body.style.padding = '0'; + document.body.style.margin = '0'; + + return {getContainerElement: () => overlayContainerElement}; + }, + }, + {provide: Directionality, useFactory: () => dir = {value: 'ltr'}}, + { + provide: ScrollDispatcher, useFactory: () => ({ + scrolled: () => scrolledSubject.asObservable(), + }), + }, + ]; + + // NgModule imports used for all mat-select tests. + const commonModuleImports = [ + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + FormsModule, + NoopAnimationsModule, + ]; + + /** + * Configures the test module for MatSelect with the given declarations. This is broken out so + * that we're only compiling the necessary test components for each test in order to speed up + * overall test time. + * @param declarations Components to declare for this block + */ + function configureMatSelectTestingModule(declarations) { TestBed.configureTestingModule({ - imports: [ - MatFormFieldModule, - MatSelectModule, - ReactiveFormsModule, - FormsModule, - NoopAnimationsModule - ], - declarations: [ - BasicSelect, - NgModelSelect, - ManySelects, - NgIfSelect, - SelectInitWithoutOptions, - SelectWithChangeEvent, - CustomSelectAccessor, - CompWithCustomSelect, - MultiSelect, - FloatPlaceholderSelect, - SelectWithErrorSibling, - ThrowsErrorOnInit, - BasicSelectOnPush, - BasicSelectOnPushPreselected, - SelectWithPlainTabindex, - SelectEarlyAccessSibling, - BasicSelectInitiallyHidden, - BasicSelectNoPlaceholder, - BasicSelectWithTheming, - ResetValuesSelect, - FalsyValueSelect, - SelectWithGroups, - InvalidSelectInForm, - BasicSelectWithoutForms, - BasicSelectWithoutFormsPreselected, - BasicSelectWithoutFormsMultiple, - SelectInsideFormGroup, - SelectWithCustomTrigger, - FalsyValueSelect, - SelectInsideFormGroup, - NgModelCompareWithSelect, - CustomErrorBehaviorSelect, - SingleSelectWithPreselectedArrayValues, - ], - providers: [ - {provide: OverlayContainer, useFactory: () => { - overlayContainerElement = document.createElement('div') as HTMLElement; - overlayContainerElement.classList.add('cdk-overlay-container'); - - document.body.appendChild(overlayContainerElement); - - // remove body padding to keep consistent cross-browser - document.body.style.padding = '0'; - document.body.style.margin = '0'; - - return {getContainerElement: () => overlayContainerElement}; - }}, - {provide: Directionality, useFactory: () => dir = { value: 'ltr' }}, - {provide: ScrollDispatcher, useFactory: () => ({ - scrolled: () => scrolledSubject.asObservable() - })} - ] + imports: commonModuleImports, + declarations: declarations, + providers: commonProviders, }).compileComponents(); - })); + } - beforeEach(fakeAsync(inject([ViewportRuler], (_ruler: ViewportRuler) => { - viewportRuler = _ruler; - }))); - - afterEach(fakeAsync(() => { + afterEach(() => { document.body.removeChild(overlayContainerElement); - })); + }); - it('should select the proper option when the list of options is initialized at a later point', - fakeAsync(() => { - let fixture = TestBed.createComponent(SelectInitWithoutOptions); - let instance = fixture.componentInstance; + describe('core', () => { + beforeEach(async(() => { + configureMatSelectTestingModule([ + BasicSelect, + MultiSelect, + SelectWithGroups, + ]); + })); + describe('accessibility', () => { + describe('for select', () => { + let fixture: ComponentFixture; + let select: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + select = fixture.debugElement.query(By.css('mat-select')).nativeElement; + })); + + it('should set the role of the select to listbox', fakeAsync(() => { + expect(select.getAttribute('role')).toEqual('listbox'); + })); + + it('should set the aria label of the select to the placeholder', fakeAsync(() => { + expect(select.getAttribute('aria-label')).toEqual('Food'); + })); + + it('should support setting a custom aria-label', fakeAsync(() => { + fixture.componentInstance.ariaLabel = 'Custom Label'; + fixture.detectChanges(); + + expect(select.getAttribute('aria-label')).toEqual('Custom Label'); + })); + + it('should not set an aria-label if aria-labelledby is specified', fakeAsync(() => { + fixture.componentInstance.ariaLabelledby = 'myLabelId'; + fixture.detectChanges(); + + expect(select.getAttribute('aria-label')).toBeFalsy('Expected no aria-label to be set.'); + expect(select.getAttribute('aria-labelledby')).toBe('myLabelId'); + })); + + it('should not have aria-labelledby in the DOM if it`s not specified', fakeAsync(() => { + fixture.detectChanges(); + expect(select.hasAttribute('aria-labelledby')).toBeFalsy(); + })); + + it('should set the tabindex of the select to 0 by default', fakeAsync(() => { + expect(select.getAttribute('tabindex')).toEqual('0'); + })); + + it('should be able to override the tabindex', fakeAsync(() => { + fixture.componentInstance.tabIndexOverride = 3; + fixture.detectChanges(); + + expect(select.getAttribute('tabindex')).toBe('3'); + })); + + it('should set aria-required for required selects', fakeAsync(() => { + expect(select.getAttribute('aria-required')) + .toEqual('false', `Expected aria-required attr to be false for normal selects.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + expect(select.getAttribute('aria-required')) + .toEqual('true', `Expected aria-required attr to be true for required selects.`); + })); + + it('should set the mat-select-required class for required selects', fakeAsync(() => { + expect(select.classList).not.toContain( + 'mat-select-required', `Expected the mat-select-required class not to be set.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + expect(select.classList).toContain( + 'mat-select-required', `Expected the mat-select-required class to be set.`); + })); + + it('should set aria-invalid for selects that are invalid and touched', fakeAsync(() => { + expect(select.getAttribute('aria-invalid')) + .toEqual('false', `Expected aria-invalid attr to be false for valid selects.`); + + fixture.componentInstance.isRequired = true; + fixture.componentInstance.control.markAsTouched(); + fixture.detectChanges(); + + expect(select.getAttribute('aria-invalid')) + .toEqual('true', `Expected aria-invalid attr to be true for invalid selects.`); + })); + + it('should set aria-disabled for disabled selects', fakeAsync(() => { + expect(select.getAttribute('aria-disabled')).toEqual('false'); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + + expect(select.getAttribute('aria-disabled')).toEqual('true'); + })); + + it('should set the tabindex of the select to -1 if disabled', fakeAsync(() => { + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('-1'); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(select.getAttribute('tabindex')).toEqual('0'); + })); + + it('should 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); + + 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); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + // Note that the third option is skipped, because it is disabled. + expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.'); + expect(formControl.value).toBe(options[3].value, + 'Expected value from fourth option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', UP_ARROW); + + 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 be able to select options by typing on a closed select', fakeAsync(() => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 80, undefined, 'p')); + tick(200); + + 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.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 69, undefined, 'e')); + tick(200); + + expect(options[5].selected).toBe(true, 'Expected sixth option to be selected.'); + expect(formControl.value).toBe(options[5].value, + 'Expected value from sixth option to have been set on the model.'); + })); + + it('should open the panel when pressing the arrow keys on a closed multiple select', + fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; + + const initialValue = instance.control.value; + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.'); + + const event = dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(instance.select.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.'); + expect(event.defaultPrevented).toBe(true, 'Expected default to be prevented.'); + })); + + it('should do nothing when typing on a closed multi-select', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + const instance = multiFixture.componentInstance; + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; + + const initialValue = instance.control.value; + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to be closed.'); + + dispatchEvent(select, createKeyboardEvent('keydown', 80, undefined, 'p')); + + expect(instance.select.panelOpen).toBe(false, 'Expected panel to stay closed.'); + expect(instance.control.value).toBe(initialValue, 'Expected value to stay the same.'); + })); + + 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. + + 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', + fakeAsync(() => { + const formControl = fixture.componentInstance.control; + + formControl.setValue('eggs-5'); + fixture.detectChanges(); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + 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', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; + multiFixture.componentInstance.select.open(); + multiFixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + options[3].focus(); + expect(document.activeElement).toBe(options[3], 'Expected fourth option to be focused.'); + + multiFixture.componentInstance.control.setValue(['steak-0', 'sushi-7']); + multiFixture.detectChanges(); + + expect(document.activeElement) + .toBe(options[3], 'Expected fourth option to remain focused.'); + })); + + 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); + + expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.'); + })); + + it('should not wrap selection after reaching the end of the options', fakeAsync(() => { + const lastOption = fixture.componentInstance.options.last; + + fixture.componentInstance.options.forEach(() => { + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + }); + + expect(lastOption.selected).toBe(true, 'Expected last option to be selected.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.'); + })); + + it('should not open a multiple select when tabbing through', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; + + expect(multiFixture.componentInstance.select.panelOpen) + .toBe(false, 'Expected panel to be closed initially.'); + + dispatchKeyboardEvent(select, 'keydown', TAB); + + expect(multiFixture.componentInstance.select.panelOpen) + .toBe(false, 'Expected panel to stay closed.'); + })); + + it('should prevent the default action when pressing space', fakeAsync(() => { + const event = dispatchKeyboardEvent(select, 'keydown', SPACE); + expect(event.defaultPrevented).toBe(true); + })); + + 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 = + option.onSelectionChange.pipe(map(e => e.isUserInput)).subscribe(spy); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + expect(spy).toHaveBeenCalledWith(true); + + subscription.unsubscribe(); + })); + + it('should be able to focus the select trigger', fakeAsync(() => { + document.body.focus(); // ensure that focus isn't on the trigger already + + fixture.componentInstance.select.focus(); + + expect(document.activeElement).toBe(select, 'Expected select element to be focused.'); + })); + + // Having `aria-hidden` on the trigger avoids issues where + // screen readers read out the wrong amount of options. + it('should set aria-hidden on the trigger element', fakeAsync(() => { + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + + expect(trigger.getAttribute('aria-hidden')) + .toBe('true', 'Expected aria-hidden to be true when the select is open.'); + })); + + it('should set `aria-multiselectable` to true on multi-select instances', fakeAsync(() => { + fixture.destroy(); + + const multiFixture = TestBed.createComponent(MultiSelect); + + multiFixture.detectChanges(); + select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; + + expect(select.getAttribute('aria-multiselectable')).toBe('true'); + })); + + it('should set aria-multiselectable false on single-selection instances', fakeAsync(() => { + 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('mat-select')).nativeElement; + + expect(host.hasAttribute('aria-activedescendant')) + .toBe(false, 'Expected no aria-activedescendant on init.'); + + fixture.componentInstance.select.open(); + fixture.detectChanges(); + flush(); + + const options = overlayContainerElement.querySelectorAll('mat-option'); + + expect(host.getAttribute('aria-activedescendant')) + .toBe(options[4].id, 'Expected aria-activedescendant to match the active option.'); + + fixture.componentInstance.select.close(); + fixture.detectChanges(); + flush(); + + 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('mat-select')).nativeElement; + + fixture.componentInstance.select.open(); + fixture.detectChanges(); + flush(); + + const options = overlayContainerElement.querySelectorAll('mat-option'); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[0].id); + + [1, 2, 3].forEach(() => { + dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + }); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[4].id); + + dispatchKeyboardEvent(host, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(host.getAttribute('aria-activedescendant')).toBe(options[3].id); + })); + + }); + + describe('for options', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + let options: NodeListOf; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + + options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + })); + + it('should set the role of mat-option to option', fakeAsync(() => { + expect(options[0].getAttribute('role')).toEqual('option'); + expect(options[1].getAttribute('role')).toEqual('option'); + expect(options[2].getAttribute('role')).toEqual('option'); + })); + + it('should set aria-selected on each option', fakeAsync(() => { + expect(options[0].getAttribute('aria-selected')).toEqual('false'); + expect(options[1].getAttribute('aria-selected')).toEqual('false'); + expect(options[2].getAttribute('aria-selected')).toEqual('false'); + + options[1].click(); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(options[0].getAttribute('aria-selected')).toEqual('false'); + expect(options[1].getAttribute('aria-selected')).toEqual('true'); + expect(options[2].getAttribute('aria-selected')).toEqual('false'); + })); + + it('should set the tabindex of each option according to disabled state', fakeAsync(() => { + expect(options[0].getAttribute('tabindex')).toEqual('0'); + expect(options[1].getAttribute('tabindex')).toEqual('0'); + expect(options[2].getAttribute('tabindex')).toEqual('-1'); + })); + + it('should set aria-disabled for disabled options', fakeAsync(() => { + expect(options[0].getAttribute('aria-disabled')).toEqual('false'); + expect(options[1].getAttribute('aria-disabled')).toEqual('false'); + expect(options[2].getAttribute('aria-disabled')).toEqual('true'); + + fixture.componentInstance.foods[2]['disabled'] = false; + fixture.detectChanges(); + + expect(options[0].getAttribute('aria-disabled')).toEqual('false'); + expect(options[1].getAttribute('aria-disabled')).toEqual('false'); + expect(options[2].getAttribute('aria-disabled')).toEqual('false'); + })); + }); + + describe('for option groups', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + let groups: NodeListOf; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SelectWithGroups); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + groups = + overlayContainerElement.querySelectorAll('mat-optgroup') as NodeListOf; + })); + + it('should set the appropriate role', fakeAsync(() => { + expect(groups[0].getAttribute('role')).toBe('group'); + })); + + it('should set the `aria-labelledby` attribute', fakeAsync(() => { + let group = groups[0]; + let label = group.querySelector('label')!; + + expect(label.getAttribute('id')).toBeTruthy('Expected label to have an id.'); + expect(group.getAttribute('aria-labelledby')) + .toBe(label.getAttribute('id'), 'Expected `aria-labelledby` to match the label id.'); + })); + + it('should set the `aria-disabled` attribute if the group is disabled', fakeAsync(() => { + expect(groups[1].getAttribute('aria-disabled')).toBe('true'); + })); + }); + }); + + describe('overlay panel', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicSelect); fixture.detectChanges(); - flush(); - - // Wait for the initial writeValue promise. - expect(instance.select.selected).toBeFalsy(); - - instance.addOptions(); - fixture.detectChanges(); - flush(); - - // Wait for the next writeValue promise. - expect(instance.select.selected).toBe(instance.options.toArray()[1]); + trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; })); - describe('overlay panel', () => { - let fixture: ComponentFixture; + it('should not throw when attempting to open too early', () => { + // Create component and then immediately open without running change detection + fixture = TestBed.createComponent(BasicSelect); + expect(() => fixture.componentInstance.select.open()).not.toThrow(); + }); + + it('should open the panel when trigger is clicked', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + 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', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should close the panel when a click occurs outside the panel', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should set the width of the overlay based on the trigger', fakeAsync(() => { + trigger.style.width = '200px'; + + trigger.click(); + fixture.detectChanges(); + flush(); + + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + expect(pane.style.minWidth).toBe('200px'); + })); + + + + it('should not attempt to open a select that does not have any options', fakeAsync(() => { + fixture.componentInstance.foods = []; + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + + + it('should close the panel when tabbing out', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + + dispatchKeyboardEvent(trigger, 'keydown', TAB); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select.panelOpen).toBe(false); + })); + + it('should focus the first option when pressing HOME', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const event = dispatchKeyboardEvent(trigger, 'keydown', HOME); + fixture.detectChanges(); + + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); + expect(event.defaultPrevented).toBe(true); + })); + + it('should focus the last option when pressing END', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const event = dispatchKeyboardEvent(trigger, 'keydown', END); + fixture.detectChanges(); + + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7); + expect(event.defaultPrevented).toBe(true); + })); + + it('should be able to set extra classes on the panel', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + + const panel = overlayContainerElement.querySelector('.mat-select-panel') as HTMLElement; + + expect(panel.classList).toContain('custom-one'); + expect(panel.classList).toContain('custom-two'); + })); + + it('should prevent the default action when pressing SPACE on an option', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelector('mat-option')!; + const event = dispatchKeyboardEvent(option, 'keydown', SPACE); + + expect(event.defaultPrevented).toBe(true); + })); + + it('should prevent the default action when pressing ENTER on an option', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelector('mat-option')!; + const event = dispatchKeyboardEvent(option, 'keydown', ENTER); + + expect(event.defaultPrevented).toBe(true); + })); + + it('should update disableRipple properly on each option', fakeAsync(() => { + const options = fixture.componentInstance.options.toArray(); + + expect(options.every(option => option.disableRipple === false)) + .toBeTruthy('Expected all options to have disableRipple set to false initially.'); + + fixture.componentInstance.disableRipple = true; + fixture.detectChanges(); + + expect(options.every(option => option.disableRipple === true)) + .toBeTruthy('Expected all options to have disableRipple set to true.'); + })); + + it('should not show ripples if they were disabled', fakeAsync(() => { + fixture.componentInstance.disableRipple = true; + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mat-option')!; + + dispatchFakeEvent(option, 'mousedown'); + dispatchFakeEvent(option, 'mouseup'); + + expect(option.querySelectorAll('.mat-ripple-element').length).toBe(0); + })); + }); + + describe('selection logic', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + let formField: HTMLElement; + + beforeEach(fakeAsync(() => { + 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; + })); + + it('should not float placeholder if no option is selected', fakeAsync(() => { + expect(formField.classList.contains('mat-form-field-should-float')) + .toBe(false, 'placeholder should not be floating'); + })); + + it('should focus the first option if no option is selected', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0); + })); + + it('should select an option when it is clicked', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + let option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + + expect(option.classList).toContain('mat-selected'); + 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', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + let options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + options[0].click(); + fixture.detectChanges(); + flush(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + expect(options[1].classList).not.toContain('mat-selected'); + expect(options[2].classList).not.toContain('mat-selected'); + + 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', fakeAsync(() => { + let control = fixture.componentInstance.control; + let foods = fixture.componentInstance.foods; + + trigger.click(); + fixture.detectChanges(); + flush(); + + let options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + options[0].click(); + fixture.detectChanges(); + flush(); + + control.setValue(foods[1].value); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + options = overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + expect(options[0].classList) + .not.toContain('mat-selected', 'Expected first option to no longer be selected'); + expect(options[1].classList) + .toContain('mat-selected', 'Expected second option to be selected'); + + const optionInstances = fixture.componentInstance.options.toArray(); + + expect(optionInstances[0].selected) + .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', fakeAsync(() => { + let select = fixture.componentInstance.select; + + trigger.click(); + fixture.detectChanges(); + flush(); + + let firstOption = overlayContainerElement.querySelectorAll('mat-option')[0] as HTMLElement; + + firstOption.click(); + fixture.detectChanges(); + + expect(select.selected).toBe(select.options.first, 'Expected first option to be selected.'); + + fixture.componentInstance.foods = []; + fixture.detectChanges(); + flush(); + + expect(select.selected) + .toBeUndefined('Expected selection to be removed when option no longer exists.'); + })); + + it('should display the selected option in the trigger', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + 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', fakeAsync(() => { + // must wait for initial writeValue promise to finish + flush(); + + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + flush(); + + // 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', fakeAsync(() => { + fixture.componentInstance.foods.push({viewValue: 'Potatoes', value: 'potatoes-8'}); + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[8].click(); + fixture.detectChanges(); + flush(); + + expect(trigger.textContent).toContain('Potatoes'); + expect(fixture.componentInstance.select.selected) + .toBe(fixture.componentInstance.options.last); + })); + + it('should not select disabled options', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[2].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + expect(options[2].classList).not.toContain('mat-selected'); + expect(fixture.componentInstance.select.selected).toBeUndefined(); + })); + + it('should not select options inside a disabled group', fakeAsync(() => { + fixture.destroy(); + + const groupFixture = TestBed.createComponent(SelectWithGroups); + groupFixture.detectChanges(); + groupFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement.click(); + groupFixture.detectChanges(); + + const disabledGroup = overlayContainerElement.querySelectorAll('mat-optgroup')[1]; + const options = disabledGroup.querySelectorAll('mat-option'); + + (options[0] as HTMLElement).click(); + groupFixture.detectChanges(); + + expect(groupFixture.componentInstance.select.panelOpen).toBe(true); + expect(options[0].classList).not.toContain('mat-selected'); + expect(groupFixture.componentInstance.select.selected).toBeUndefined(); + })); + + it('should not throw if triggerValue accessed with no selected value', fakeAsync(() => { + expect(() => fixture.componentInstance.select.triggerValue).not.toThrow(); + })); + }); + + describe('forms integration', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + })); + + it('should take an initial view value with reactive forms', fakeAsync(() => { + fixture.componentInstance.control = new FormControl('pizza-1'); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mat-select-value')); + expect(value.nativeElement.textContent) + .toContain('Pizza', `Expected trigger to be populated by the control's initial value.`); + + trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mat-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', fakeAsync(() => { + let value = fixture.debugElement.query(By.css('.mat-select-value')); + expect(value.nativeElement.textContent.trim()).toBe(''); + + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + value = fixture.debugElement.query(By.css('.mat-select-value')); + expect(value.nativeElement.textContent) + .toContain('Pizza', `Expected trigger to be populated by the control's new value.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mat-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', fakeAsync(() => { + expect(fixture.componentInstance.control.value) + .toEqual(null, `Expected the control's value to be empty initially.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + 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', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + fixture.componentInstance.control.setValue('gibberish'); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mat-select-value')); + expect(value.nativeElement.textContent.trim()) + .toBe('', `Expected trigger to be cleared when option value is not found.`); + expect(trigger.textContent) + .not.toContain('Pizza', `Expected trigger is cleared when option value is not found.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + expect(options[1].classList) + .not.toContain('mat-selected', `Expected option w/ the old value not to be selected.`); + })); + + + it('should clear the selection when the control is reset', fakeAsync(() => { + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + fixture.componentInstance.control.reset(); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.mat-select-value')); + expect(value.nativeElement.textContent.trim()) + .toBe('', `Expected trigger to be cleared when option value is not found.`); + expect(trigger.textContent) + .not.toContain('Pizza', `Expected trigger is cleared when option value is not found.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + expect(options[1].classList) + .not.toContain('mat-selected', `Expected option w/ the old value not to be selected.`); + })); + + 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(); + flush(); + + expect(fixture.componentInstance.control.touched) + .toEqual(false, `Expected the control to stay untouched when menu opened.`); + + const backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + dispatchFakeEvent(trigger, 'blur'); + fixture.detectChanges(); + flush(); + + 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', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + dispatchFakeEvent(trigger, 'blur'); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + })); + + it('should set the control to dirty when the select value changes in DOM', fakeAsync(() => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + trigger.click(); + fixture.detectChanges(); + flush(); + + const option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + flush(); + + 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', + fakeAsync(() => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + fixture.componentInstance.control.setValue('pizza-1'); + + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to stay pristine after programmatic change.`); + })); + + it('should set an asterisk after the placeholder if control is required', fakeAsync(() => { + let requiredMarker = fixture.debugElement.query(By.css('.mat-form-field-required-marker')); + expect(requiredMarker) + .toBeNull(`Expected placeholder not to have an asterisk, as control was not required.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + requiredMarker = fixture.debugElement.query(By.css('.mat-form-field-required-marker')); + expect(requiredMarker) + .not.toBeNull(`Expected placeholder to have an asterisk, as control was required.`); + })); + }); + + describe('disabled behavior', () => { + it('should disable itself when control is disabled programmatically', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + let trigger = + fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('default', `Expected cursor to be default arrow on disabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toEqual('', `Expected select panel to stay closed.`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(false, `Expected select panelOpen property to stay false.`); + + fixture.componentInstance.control.enable(); + fixture.detectChanges(); + expect(getComputedStyle(trigger).getPropertyValue('cursor')) + .toEqual('pointer', `Expected cursor to be a pointer on enabled control.`); + + trigger.click(); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toContain('Steak', `Expected select panel to open normally on re-enabled control`); + expect(fixture.componentInstance.select.panelOpen) + .toBe(true, `Expected select panelOpen property to become true.`); + })); + }); + + describe('animations', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + let formField: HTMLElement; + + beforeEach(fakeAsync(() => { + 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; + })); + + 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.'); + + fixture.componentInstance.select.open(); + fixture.detectChanges(); + flush(); + + expect(formField.classList).toContain('mat-form-field-should-float', + 'Expected placeholder to animate up to floating position.'); + + fixture.componentInstance.select.close(); + fixture.detectChanges(); + flush(); + + 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(); + fixture.detectChanges(); + + const panel = overlayContainerElement.querySelector('.mat-select-panel')!; + + expect(panel.classList).not.toContain('mat-select-panel-done-animating'); + + flush(); + fixture.detectChanges(); + + expect(panel.classList).toContain('mat-select-panel-done-animating'); + })); + }); + + 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(); + flush(); + + host = fixture.debugElement.query(By.css('mat-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); + }); + + 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); + } + + //