/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/material/autocomplete/autocomplete.spec.ts
4 400 строк
154 KB
Kristiyan Kostadinov
refactor(material/autocomplete): remove explicit change detection
09 май 2026, 06:37
09 май 2026, 06:37
2c2a226
Код
Авторство
О чём код?
import {Direction} from '@angular/cdk/bidi'; import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; import {createCloseScrollStrategy} from '@angular/cdk/overlay'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { clearElement, createKeyboardEvent, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, provideFakeDirectionality, typeInElement, } from '@angular/cdk/testing/private'; import { ChangeDetectionStrategy, Component, Injector, OnDestroy, OnInit, Provider, QueryList, Type, ViewChild, ViewChildren, ViewEncapsulation, signal, } from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {AsyncPipe} from '@angular/common'; import {By} from '@angular/platform-browser'; import {Observable, Subject, Subscription} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; import { MATERIAL_ANIMATIONS, MatOptgroup, MatOption, MatOptionSelectionChange, ThemePalette, } from '../core'; import {FloatLabelType, MatFormField} from '../form-field'; import {MatInputModule} from '../input'; import { MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocomplete, MatAutocompleteDefaultOptions, MatAutocompleteOrigin, MatAutocompleteSelectedEvent, MatAutocompleteTrigger, getMatAutocompleteMissingPanelError, } from './index'; describe('MatAutocomplete', () => { // Creates a test component fixture. function createComponent<T>(component: Type<T>, providers: Provider[] = []) { TestBed.configureTestingModule({ providers: [ ...providers, {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], }); return TestBed.createComponent<T>(component); } function getOverlayHost(fixture: ComponentFixture<unknown>): HTMLElement | null { return fixture.nativeElement.querySelector('.cdk-overlay-popover'); } function getBackdrop(fixture: ComponentFixture<unknown>): HTMLElement | null { return ( fixture.nativeElement.querySelector('.cdk-overlay-popover .cdk-overlay-backdrop') || null ); } function wait(milliseconds: number) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } describe('panel toggling', () => { let fixture: ComponentFixture<SimpleAutocomplete>; let input: HTMLInputElement; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); input = fixture.debugElement.query(By.css('input'))!.nativeElement; }); it('should open the panel when the input is focused', () => { expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to start out closed.`) .toBe(false); dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to read open when input is focused.`) .toBe(true); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when input is focused.`) .toContain('Alabama'); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when input is focused.`) .toContain('California'); }); it('should not open the panel on focus if the input is readonly', async () => { const trigger = fixture.componentInstance.trigger; input.readOnly = true; fixture.detectChanges(); expect(trigger.panelOpen) .withContext('Expected panel state to start out closed.') .toBe(false); dispatchFakeEvent(input, 'focusin'); await fixture.whenStable(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to stay closed.').toBe(false); }); it('should not open using the arrow keys when the input is readonly', async () => { const trigger = fixture.componentInstance.trigger; input.readOnly = true; fixture.detectChanges(); expect(trigger.panelOpen) .withContext('Expected panel state to start out closed.') .toBe(false); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); await fixture.whenStable(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to stay closed.').toBe(false); }); it('should open the panel programmatically', () => { expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to start out closed.`) .toBe(false); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to read open when opened programmatically.`) .toBe(true); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when opened programmatically.`) .toContain('Alabama'); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when opened programmatically.`) .toContain('California'); }); it('should close the panel when the user clicks away', async () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); await fixture.whenStable(); dispatchFakeEvent(document, 'click'); await fixture.whenStable(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected clicking outside the panel to set its state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected clicking outside the panel to close the panel.`) .toBeFalsy(); }); it('should close the panel when the user clicks away via auxiliary button', async () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); await fixture.whenStable(); dispatchFakeEvent(document, 'auxclick'); await fixture.whenStable(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected clicking outside the panel to set its state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected clicking outside the panel to close the panel.`) .toBeFalsy(); }); it('should close the panel when the user taps away on a touch device', async () => { dispatchFakeEvent(input, 'focus'); fixture.detectChanges(); await fixture.whenStable(); dispatchFakeEvent(document, 'touchend'); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected tapping outside the panel to set its state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected tapping outside the panel to close the panel.`) .toBeFalsy(); }); it('should close the panel when an option is clicked', () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; option.click(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected clicking an option to set the panel state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected clicking an option to close the panel.`) .toBeFalsy(); }); it('should close the panel when a newly created option is clicked', () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); // Filter down the option list to a subset of original options ('Alabama', 'California') typeInElement(input, 'al'); fixture.detectChanges(); let options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[0].click(); // Changing value from 'Alabama' to 'al' to re-populate the option list, // ensuring that 'California' is created new. dispatchFakeEvent(input, 'focusin'); clearElement(input); typeInElement(input, 'al'); fixture.detectChanges(); options = getOverlayHost(fixture)!.querySelectorAll('mat-option') as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected clicking a new option to set the panel state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected clicking a new option to close the panel.`) .toBeFalsy(); }); it('should close the panel programmatically', async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected closing programmatically to set the panel state to closed.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected closing programmatically to close the panel.`) .toBeFalsy(); }); it('should not throw when attempting to close the panel of a destroyed autocomplete', () => { const trigger = fixture.componentInstance.trigger; trigger.openPanel(); fixture.detectChanges(); fixture.destroy(); expect(() => trigger.closePanel()).not.toThrow(); }); it('should hide the panel when the options list is empty', async () => { dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); const panel = getOverlayHost(fixture)!.querySelector( '.mat-mdc-autocomplete-panel', ) as HTMLElement; expect(panel.classList) .withContext(`Expected panel to start out visible.`) .toContain('mat-mdc-autocomplete-visible'); // Filter down the option list such that no options match the value typeInElement(input, 'af'); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(panel.classList) .withContext(`Expected panel to hide itself when empty.`) .toContain('mat-mdc-autocomplete-hidden'); }); it('should keep the label floating until the panel closes', () => { fixture.componentInstance.trigger.openPanel(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to float as soon as panel opens.') .toEqual('always'); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to return to auto state after panel closes.') .toEqual('auto'); }); it('should not open the panel when the `input` event is invoked on a non-focused input', () => { expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to start out closed.`) .toBe(false); input.value = 'Alabama'; dispatchFakeEvent(input, 'input'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to stay closed.`) .toBe(false); }); it('should not mess with label placement if set to never', async () => { // TODO(crisbeto): this test likely doesn't make sense anymore. fixture.componentInstance.floatLabel = 'never' as any; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to stay static.') .toEqual('never'); await fixture.whenStable(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to stay in static state after close.') .toEqual('never'); }); it('should not mess with label placement if set to always', async () => { fixture.componentInstance.floatLabel = 'always'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to stay elevated on open.') .toEqual('always'); await fixture.whenStable(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.formField.floatLabel) .withContext('Expected label to stay elevated after close.') .toEqual('always'); }); it('should toggle the visibility when typing and closing the panel', async () => { fixture.componentInstance.trigger.openPanel(); await fixture.whenStable(); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!.classList) .withContext('Expected panel to be visible.') .toContain('mat-mdc-autocomplete-visible'); typeInElement(input, 'x'); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!.classList) .withContext('Expected panel to be hidden.') .toContain('mat-mdc-autocomplete-hidden'); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); clearElement(input); typeInElement(input, 'al'); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!.classList) .withContext('Expected panel to be visible.') .toContain('mat-mdc-autocomplete-visible'); }); it('should animate the label when the input is focused', () => { const inputContainer = fixture.componentInstance.formField; spyOn(inputContainer, '_animateAndLockLabel'); expect(inputContainer._animateAndLockLabel).not.toHaveBeenCalled(); dispatchFakeEvent(fixture.debugElement.query(By.css('input'))!.nativeElement, 'focusin'); expect(inputContainer._animateAndLockLabel).toHaveBeenCalled(); }); it('should provide the open state of the panel', async () => { expect(fixture.componentInstance.panel.isOpen).toBeFalsy( `Expected the panel to be unopened initially.`, ); dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.panel.isOpen).toBeTruthy( `Expected the panel to be opened on focus.`, ); }); it('should emit an event when the panel is opened', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).toHaveBeenCalled(); }); it('should not emit the `opened` event when no options are being shown', () => { fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).not.toHaveBeenCalled(); }); it('should emit the `opened` event if the options come in after the panel is shown', async () => { fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).not.toHaveBeenCalled(); fixture.componentInstance.filteredStates = fixture.componentInstance.states = [ {name: 'California', code: 'CA'}, ]; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).toHaveBeenCalled(); }); it('should not emit the opened event multiple times while typing', async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); typeInElement(input, 'Alabam'); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); }); it('should emit an event when the panel is closed', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); expect(fixture.componentInstance.closedSpy).toHaveBeenCalled(); }); it('should not emit the `closed` event when no options were shown', () => { fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); expect(fixture.componentInstance.closedSpy).not.toHaveBeenCalled(); }); it('should not be able to open the panel if the autocomplete is disabled', () => { expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to start out closed.`) .toBe(false); fixture.componentInstance.autocompleteDisabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel to remain closed.`) .toBe(false); }); it('should continue to update the model if the autocomplete is disabled', () => { fixture.componentInstance.autocompleteDisabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); typeInElement(input, 'hello'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value).toBe('hello'); }); it('should set aria-haspopup depending on whether the autocomplete is disabled', () => { expect(input.getAttribute('aria-haspopup')).toBe('listbox'); fixture.componentInstance.autocompleteDisabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(input.hasAttribute('aria-haspopup')).toBe(false); }); it('should reopen the panel when clicking on the input', async () => { const trigger = fixture.componentInstance.trigger; input.focus(); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).withContext('Expected input to be focused.').toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); trigger.closePanel(); fixture.detectChanges(); expect(document.activeElement) .withContext('Expected input to continue to be focused.') .toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); input.click(); await fixture.whenStable(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to reopen on click.').toBe(true); }); }); it('should not close the panel when clicking on the input', () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext('Expected panel to be opened on focus.') .toBe(true); input.click(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext('Expected panel to remain opened after clicking on the input.') .toBe(true); }); it('should not close the panel when clicking on the input inside shadow DOM', () => { // This test is only relevant for Shadow DOM-capable browsers. if (!_supportsShadowDom()) { return; } const fixture = createComponent(SimpleAutocompleteShadowDom); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; dispatchFakeEvent(input, 'focusin'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext('Expected panel to be opened on focus.') .toBe(true); input.click(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext('Expected panel to remain opened after clicking on the input.') .toBe(true); }); it('should have the correct text direction in RTL', () => { const rtlFixture = createComponent(SimpleAutocomplete, [provideFakeDirectionality('rtl')]); rtlFixture.detectChanges(); rtlFixture.componentInstance.trigger.openPanel(); rtlFixture.detectChanges(); expect(getOverlayHost(rtlFixture)?.getAttribute('dir')).toEqual('rtl'); }); it('should update the panel direction if it changes for the trigger', () => { const dir = signal<Direction>('rtl'); const rtlFixture = createComponent(SimpleAutocomplete, [provideFakeDirectionality(dir)]); rtlFixture.detectChanges(); rtlFixture.componentInstance.trigger.openPanel(); rtlFixture.detectChanges(); expect(getOverlayHost(rtlFixture)?.getAttribute('dir')).toEqual('rtl'); rtlFixture.componentInstance.trigger.closePanel(); rtlFixture.detectChanges(); dir.set('ltr'); rtlFixture.componentInstance.trigger.openPanel(); rtlFixture.detectChanges(); expect(getOverlayHost(rtlFixture)?.getAttribute('dir')).toEqual('ltr'); }); it('should be able to set a custom value for the `autocomplete` attribute', () => { const fixture = createComponent(AutocompleteWithNativeAutocompleteAttribute); const input = fixture.nativeElement.querySelector('input'); fixture.detectChanges(); expect(input.getAttribute('autocomplete')).toBe('changed'); }); it('should not throw when typing in an element with a null and disabled autocomplete', () => { const fixture = createComponent(InputWithoutAutocompleteAndDisabled); fixture.detectChanges(); expect(() => { dispatchKeyboardEvent(fixture.nativeElement.querySelector('input'), 'keydown', SPACE); fixture.detectChanges(); }).not.toThrow(); }); it('should clear the selected option if it no longer matches the input text while typing', () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); // Select an option and reopen the panel. (getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement).click(); fixture.detectChanges(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.options.first.selected).toBe(true); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; input.value = ''; typeInElement(input, 'Ala'); fixture.detectChanges(); expect(fixture.componentInstance.options.first.selected).toBe(false); }); it('should not clear the selected option if it no longer matches the input text while typing with requireSelection', () => { const fixture = createComponent(SimpleAutocomplete); fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); // Select an option and reopen the panel. (getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement).click(); fixture.detectChanges(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.options.first.selected).toBe(true); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; input.value = ''; typeInElement(input, 'Ala'); fixture.detectChanges(); expect(fixture.componentInstance.options.first.selected).toBe(true); }); describe('forms integration', () => { let fixture: ComponentFixture<SimpleAutocomplete>; let input: HTMLInputElement; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); input = fixture.debugElement.query(By.css('input'))!.nativeElement; }); it('should update control value as user types with input value', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); typeInElement(input, 'a'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value) .withContext('Expected control value to be updated as user types.') .toEqual('a'); typeInElement(input, 'l'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value) .withContext('Expected control value to be updated as user types.') .toEqual('al'); }); it('should update control value when autofilling', () => { // Simulate the browser autofilling the input by setting a value and // dispatching an `input` event while the input is out of focus. expect(document.activeElement).not.toBe(input, 'Expected input not to have focus.'); input.value = 'Alabama'; dispatchFakeEvent(input, 'input'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value) .withContext('Expected value to be propagated to the form control.') .toBe('Alabama'); }); it('should update control value when option is selected with option value', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value) .withContext('Expected control value to equal the selected option value.') .toEqual({code: 'CA', name: 'California'}); }); it('should update the control back to a string if user types after an option is selected', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); clearElement(input); typeInElement(input, 'Californi'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value) .withContext('Expected control value to revert back to string.') .toEqual('Californi'); }); it('should fill the text field with display value when an option is selected', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(input.value) .withContext(`Expected text field to fill with selected value.`) .toContain('California'); }); it('should fill the text field with value if displayWith is not set', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.componentInstance.panel.displayWith = null; fixture.componentInstance.options.toArray()[1].value = 'test value'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(input.value) .withContext(`Expected input to fall back to selected option's value.`) .toContain('test value'); }); it('should fill the text field correctly if value is set to obj programmatically', async () => { fixture.componentInstance.stateCtrl.setValue({code: 'AL', name: 'Alabama'}); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(input.value) .withContext(`Expected input to fill with matching option's viewValue.`) .toContain('Alabama'); }); it('should clear the text field if value is reset programmatically', async () => { typeInElement(input, 'Alabama'); fixture.detectChanges(); fixture.componentInstance.stateCtrl.reset(); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).withContext(`Expected input value to be empty after reset.`).toEqual(''); }); it('should clear the previous selection when reactive form field is reset programmatically', async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; const clickedOption = options[0]; const option = fixture.componentInstance.options.first; clickedOption.click(); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); expect(option.selected).toBe(true); fixture.componentInstance.stateCtrl.reset(); await fixture.whenStable(); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.stateCtrl.value).toEqual(null); expect(option.selected).toBe(false); }); it('should disable input in view when disabled programmatically', () => { const formFieldElement = fixture.debugElement.query( By.css('.mat-mdc-form-field'), )!.nativeElement; expect(input.disabled) .withContext(`Expected input to start out enabled in view.`) .toBe(false); expect(formFieldElement.classList.contains('mat-form-field-disabled')) .withContext(`Expected input underline to start out with normal styles.`) .toBe(false); fixture.componentInstance.stateCtrl.disable(); fixture.detectChanges(); expect(input.disabled) .withContext(`Expected input to be disabled in view when disabled programmatically.`) .toBe(true); expect(formFieldElement.classList.contains('mat-form-field-disabled')) .withContext(`Expected input underline to display disabled styles.`) .toBe(true); }); it('should mark the autocomplete control as dirty as user types', () => { expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to start out pristine.`) .toBe(false); typeInElement(input, 'a'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to become dirty when the user types into the input.`) .toBe(true); }); it('should mark the autocomplete control as dirty when an option is selected', () => { expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to start out pristine.`) .toBe(false); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to become dirty when an option was selected.`) .toBe(true); }); it('should not mark the control dirty when the value is set programmatically', () => { expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to start out pristine.`) .toBe(false); fixture.componentInstance.stateCtrl.setValue('AL'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to stay pristine if value is set programmatically.`) .toBe(false); }); it('should mark the autocomplete control as touched on blur', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.touched) .withContext(`Expected control to start out untouched.`) .toBe(false); dispatchFakeEvent(input, 'blur'); fixture.detectChanges(); expect(fixture.componentInstance.stateCtrl.touched) .withContext(`Expected control to become touched on blur.`) .toBe(true); }); it('should disable the input when used with a value accessor and without `matInput`', () => { fixture.destroy(); TestBed.resetTestingModule(); const plainFixture = createComponent(PlainAutocompleteInputWithFormControl); plainFixture.detectChanges(); input = plainFixture.nativeElement.querySelector('input'); expect(input.disabled).toBe(false); plainFixture.componentInstance.formControl.disable(); plainFixture.detectChanges(); expect(input.disabled).toBe(true); }); }); describe('with theming', () => { let fixture: ComponentFixture<SimpleAutocomplete>; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); }); it('should transfer the theme to the autocomplete panel', () => { fixture.componentInstance.theme = 'warn'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = getOverlayHost(fixture)!.querySelector( '.mat-mdc-autocomplete-panel', )! as HTMLElement; expect(panel.classList).toContain('mat-warn'); }); }); describe('keyboard events', () => { let fixture: ComponentFixture<SimpleAutocomplete>; let input: HTMLInputElement; let DOWN_ARROW_EVENT: KeyboardEvent; let UP_ARROW_EVENT: KeyboardEvent; let ENTER_EVENT: KeyboardEvent; beforeEach(async () => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); input = fixture.debugElement.query(By.css('input'))!.nativeElement; DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); ENTER_EVENT = createKeyboardEvent('keydown', ENTER); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); }); it('should not focus the option when DOWN key is pressed', () => { spyOn(fixture.componentInstance.options.first, 'focus'); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled(); }); it('should not close the panel when DOWN key is pressed', () => { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to stay open when DOWN key is pressed.`) .toBe(true); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to keep displaying when DOWN key is pressed.`) .toContain('Alabama'); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to keep displaying when DOWN key is pressed.`) .toContain('California'); }); it('should set the active item to the first option when DOWN key is pressed', () => { const componentInstance = fixture.componentInstance; const optionEls = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; expect(componentInstance.trigger.panelOpen) .withContext('Expected first down press to open the pane.') .toBe(true); componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(componentInstance.trigger.activeOption === componentInstance.options.first) .withContext('Expected first option to be active.') .toBe(true); expect(optionEls[0].classList).toContain('mat-mdc-option-active'); expect(optionEls[1].classList).not.toContain('mat-mdc-option-active'); componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(componentInstance.trigger.activeOption === componentInstance.options.toArray()[1]) .withContext('Expected second option to be active.') .toBe(true); expect(optionEls[0].classList).not.toContain('mat-mdc-option-active'); expect(optionEls[1].classList).toContain('mat-mdc-option-active'); }); it('should set the active item to the last option when UP key is pressed', () => { const componentInstance = fixture.componentInstance; const optionEls = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; expect(componentInstance.trigger.panelOpen) .withContext('Expected first up press to open the pane.') .toBe(true); componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); fixture.detectChanges(); expect(componentInstance.trigger.activeOption === componentInstance.options.last) .withContext('Expected last option to be active.') .toBe(true); expect(optionEls[10].classList).toContain('mat-mdc-option-active'); expect(optionEls[0].classList).not.toContain('mat-mdc-option-active'); componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(componentInstance.trigger.activeOption === componentInstance.options.first) .withContext('Expected first option to be active.') .toBe(true); expect(optionEls[0].classList).toContain('mat-mdc-option-active'); }); it('should set the active item properly after filtering', () => { const componentInstance = fixture.componentInstance; typeInElement(input, 'o'); fixture.detectChanges(); componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); const optionEls = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; expect(componentInstance.trigger.activeOption === componentInstance.options.first) .withContext('Expected first option to be active.') .toBe(true); expect(optionEls[0].classList).toContain('mat-mdc-option-active'); expect(optionEls[1].classList).not.toContain('mat-mdc-option-active'); }); it('should fill the text field when an option is selected with ENTER', async () => { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); fixture.detectChanges(); await fixture.whenStable(); expect(input.value) .withContext(`Expected text field to fill with selected value on ENTER.`) .toContain('Alabama'); }); it('should prevent the default enter key action', async () => { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); await fixture.whenStable(); expect(ENTER_EVENT.defaultPrevented) .withContext('Expected the default action to have been prevented.') .toBe(true); }); it('should not prevent the default enter action for a closed panel after a user action', () => { fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); fixture.detectChanges(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); expect(ENTER_EVENT.defaultPrevented) .withContext('Default action should not be prevented.') .toBe(false); }); it('should not interfere with the ENTER key when pressing a modifier', async () => { const trigger = fixture.componentInstance.trigger; expect(input.value).withContext('Expected input to start off blank.').toBeFalsy(); expect(trigger.panelOpen).withContext('Expected panel to start off open.').toBe(true); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); Object.defineProperty(ENTER_EVENT, 'altKey', {get: () => true}); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to remain open.').toBe(true); expect(input.value).withContext('Expected input to remain blank.').toBeFalsy(); expect(ENTER_EVENT.defaultPrevented) .withContext('Expected the default ENTER action not to have been prevented.') .toBe(false); }); it('should fill the text field, not select an option, when SPACE is entered', () => { typeInElement(input, 'New'); fixture.detectChanges(); const SPACE_EVENT = createKeyboardEvent('keydown', SPACE); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT); fixture.detectChanges(); expect(input.value) .not.withContext(`Expected option not to be selected on SPACE.`) .toContain('New York'); }); it('should mark the control dirty when selecting an option from the keyboard', async () => { expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to start out pristine.`) .toBe(false); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.stateCtrl.dirty) .withContext(`Expected control to become dirty when option was selected by ENTER.`) .toBe(true); }); it('should open the panel again when typing after making a selection', async () => { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); fixture.detectChanges(); await fixture.whenStable(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to read closed after ENTER key.`) .toBe(false); expect(getOverlayHost(fixture)) .withContext(`Expected panel to close after ENTER key.`) .toBeFalsy(); dispatchFakeEvent(input, 'focusin'); clearElement(input); typeInElement(input, 'Alabama'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to read open when typing in input.`) .toBe(true); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when typing in input.`) .toContain('Alabama'); }); it('should not open the panel if the `input` event was dispatched with changing the value', () => { const trigger = fixture.componentInstance.trigger; dispatchFakeEvent(input, 'focusin'); typeInElement(input, 'A'); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); trigger.closePanel(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); // Dispatch the event without actually changing the value // to simulate what happen in some cases on IE. dispatchFakeEvent(input, 'input'); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to stay closed.').toBe(false); }); it('should scroll to active options below the fold', () => { const trigger = fixture.componentInstance.trigger; const scrollContainer = document.querySelector( '.cdk-overlay-pane .mat-mdc-autocomplete-panel', )!; trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(scrollContainer.scrollTop).withContext(`Expected panel not to scroll.`).toEqual(0); // These down arrows will set the 6th option active, below the fold. [1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); // Expect option bottom minus the panel height plus padding (288 - 256 + 8 = 40) // Expect option bottom minus the panel height plus padding (288 - 256 + 8 = 40) expect(scrollContainer.scrollTop) .withContext(`Expected panel to reveal the sixth option.`) .toEqual(40); }); it('should scroll to active options below if the option height is variable', () => { // Make every other option a bit taller than the base of 48. fixture.componentInstance.states.forEach((state, index) => { if (index % 2 === 0) { state.height = 64; } }); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const trigger = fixture.componentInstance.trigger; const scrollContainer = document.querySelector( '.cdk-overlay-pane .mat-mdc-autocomplete-panel', )!; trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(scrollContainer.scrollTop).withContext(`Expected panel not to scroll.`).toEqual(0); // These down arrows will set the 6th option active, below the fold. [1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); // Expect option bottom minus the panel height (336 - 256 + 8 = 88) // Expect option bottom minus the panel height (336 - 256 + 8 = 88) expect(scrollContainer.scrollTop) .withContext(`Expected panel to reveal the sixth option.`) .toEqual(88); }); it('should scroll to active options on UP arrow', () => { const scrollContainer = document.querySelector( '.cdk-overlay-pane .mat-mdc-autocomplete-panel', )!; const initialScrollTop = scrollContainer.scrollTop; fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); fixture.detectChanges(); expect(scrollContainer.scrollTop) .withContext(`Expected panel to reveal last option.`) .toBeGreaterThan(initialScrollTop); }); it('should not scroll to active options that are fully in the panel', () => { const trigger = fixture.componentInstance.trigger; const scrollContainer = document.querySelector( '.cdk-overlay-pane .mat-mdc-autocomplete-panel', )!; trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(scrollContainer.scrollTop).withContext(`Expected panel not to scroll.`).toEqual(0); // These down arrows will set the 6th option active, below the fold. [1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); // Expect option bottom minus the panel height plus the padding (288 - 256 + 8 = 40) // Expect option bottom minus the panel height plus the padding (288 - 256 + 8 = 40) expect(scrollContainer.scrollTop) .withContext(`Expected panel to reveal the sixth option.`) .toEqual(40); // These up arrows will set the 2nd option active [4, 3, 2, 1].forEach(() => trigger._handleKeydown(UP_ARROW_EVENT)); // Expect no scrolling to have occurred. Still showing bottom of 6th option. // Expect no scrolling to have occurred. Still showing bottom of 6th option. expect(scrollContainer.scrollTop) .withContext(`Expected panel not to scroll up since sixth option still fully visible.`) .toEqual(40); }); it('should scroll to active options that are above the panel', () => { const trigger = fixture.componentInstance.trigger; const scrollContainer = document.querySelector( '.cdk-overlay-pane .mat-mdc-autocomplete-panel', )!; trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(scrollContainer.scrollTop).withContext(`Expected panel not to scroll.`).toEqual(0); // These down arrows will set the 7th option active, below the fold. [1, 2, 3, 4, 5, 6].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); // These up arrows will set the 2nd option active [5, 4, 3, 2, 1].forEach(() => trigger._handleKeydown(UP_ARROW_EVENT)); // Expect to show the top of the 2nd option at the top of the panel // Expect to show the top of the 2nd option at the top of the panel expect(scrollContainer.scrollTop) .withContext(`Expected panel to scroll up when option is above panel.`) .toEqual(56); }); it('should close the panel when pressing escape', async () => { const trigger = fixture.componentInstance.trigger; input.focus(); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).withContext('Expected input to be focused.').toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); fixture.detectChanges(); await fixture.whenStable(); expect(document.activeElement) .withContext('Expected input to continue to be focused.') .toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); }); it('should prevent the default action when pressing escape', () => { const escapeEvent = dispatchKeyboardEvent(input, 'keydown', ESCAPE); fixture.detectChanges(); expect(escapeEvent.defaultPrevented).toBe(true); }); it('should not close the panel when pressing escape with a modifier', async () => { const trigger = fixture.componentInstance.trigger; input.focus(); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).withContext('Expected input to be focused.').toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); const event = dispatchKeyboardEvent(document.body, 'keydown', ESCAPE, undefined, {alt: true}); fixture.detectChanges(); await fixture.whenStable(); expect(document.activeElement) .withContext('Expected input to continue to be focused.') .toBe(input); expect(trigger.panelOpen).withContext('Expected panel to stay open.').toBe(true); expect(event.defaultPrevented) .withContext('Expected default action not to be prevented.') .toBe(false); }); it('should close the panel when pressing ALT + UP_ARROW', async () => { const trigger = fixture.componentInstance.trigger; const upArrowEvent = createKeyboardEvent('keydown', UP_ARROW, undefined, {alt: true}); spyOn(upArrowEvent, 'stopPropagation').and.callThrough(); input.focus(); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).withContext('Expected input to be focused.').toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); dispatchEvent(document.body, upArrowEvent); fixture.detectChanges(); await fixture.whenStable(); expect(document.activeElement) .withContext('Expected input to continue to be focused.') .toBe(input); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); expect(upArrowEvent.stopPropagation).toHaveBeenCalled(); }); it('should close the panel when tabbing away from a trigger without results', async () => { fixture.componentInstance.states = []; fixture.componentInstance.filteredStates = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); input.focus(); await fixture.whenStable(); expect(getOverlayHost(fixture)?.querySelector('.mat-mdc-autocomplete-panel')) .withContext('Expected panel to be rendered.') .toBeTruthy(); dispatchKeyboardEvent(input, 'keydown', TAB); fixture.detectChanges(); await fixture.whenStable(); expect(getOverlayHost(fixture)?.querySelector('.mat-mdc-autocomplete-panel')) .withContext('Expected panel to be removed.') .toBeFalsy(); }); it('should not close when a click event occurs on the outside while the panel has focus', async () => { const trigger = fixture.componentInstance.trigger; input.focus(); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).toBe(input, 'Expected input to be focused.'); expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); dispatchMouseEvent(document.body, 'click'); fixture.detectChanges(); await fixture.whenStable(); expect(document.activeElement).toBe(input, 'Expected input to continue to be focused.'); expect(trigger.panelOpen).toBe(true, 'Expected panel to stay open.'); }); it('should reset the active option when closing with the escape key', async () => { const trigger = fixture.componentInstance.trigger; trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); expect(!!trigger.activeOption).withContext('Expected no active option.').toBe(false); // Press the down arrow a few times. for (const _ of [1, 2, 3]) { trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); } // Note that this casts to a boolean, in order to prevent Jasmine // from crashing when trying to stringify the option if the test fails. expect(!!trigger.activeOption).withContext('Expected to find an active option.').toBe(true); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); await fixture.whenStable(); expect(!!trigger.activeOption).withContext('Expected no active options.').toBe(false); }); it('should reset the active option when closing by selecting with enter', async () => { const trigger = fixture.componentInstance.trigger; trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); expect(!!trigger.activeOption).withContext('Expected no active option.').toBe(false); // Press the down arrow a few times. for (const _ of [1, 2, 3]) { trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); } // Note that this casts to a boolean, in order to prevent Jasmine // from crashing when trying to stringify the option if the test fails. expect(!!trigger.activeOption).withContext('Expected to find an active option.').toBe(true); trigger._handleKeydown(ENTER_EVENT); await fixture.whenStable(); expect(!!trigger.activeOption).withContext('Expected no active options.').toBe(false); }); it('should not prevent the default action when a modifier key is pressed', () => { ['metaKey', 'ctrlKey', 'altKey', 'shiftKey'].forEach(name => { const event = createKeyboardEvent('keydown', DOWN_ARROW); Object.defineProperty(event, name, {get: () => true}); fixture.componentInstance.trigger._handleKeydown(event); fixture.detectChanges(); expect(event.defaultPrevented) .withContext(`Expected autocomplete not to block ${name} key`) .toBe(false); }); }); }); describe('option groups', () => { let DOWN_ARROW_EVENT: KeyboardEvent; let UP_ARROW_EVENT: KeyboardEvent; beforeEach(() => { DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); }); it('should scroll to active options below the fold', () => { const fixture = createComponent(AutocompleteWithGroups); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(container.scrollTop).withContext('Expected the panel not to scroll.').toBe(0); // Press the down arrow five times. for (const _unused of [1, 2, 3, 4, 5]) { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); } // <option bottom> - <panel height> + <2x group labels> + panel padding = 136 // 288 - 256 + 96 + 8 = 128 // <option bottom> - <panel height> + <2x group labels> + panel padding = 136 // 288 - 256 + 96 + 8 = 128 expect(container.scrollTop) .withContext('Expected panel to reveal the sixth option.') .toBe(136); }); it('should scroll to active options on UP arrow', () => { const fixture = createComponent(AutocompleteWithGroups); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); fixture.detectChanges(); // <option bottom> - <panel height> + <3x group label> + panel padding = 472 // 576 - 256 + 144 + 8 = 472 // <option bottom> - <panel height> + <3x group label> + panel padding = 472 // 576 - 256 + 144 + 8 = 472 expect(container.scrollTop).withContext('Expected panel to reveal last option.').toBe(472); }); it('should scroll to active options that are above the panel', async () => { const fixture = createComponent(AutocompleteWithGroups); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); expect(container.scrollTop).withContext('Expected panel not to scroll.').toBe(0); // These down arrows will set the 7th option active, below the fold. for (const _ of [1, 2, 3, 4, 5, 6]) { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); } // These up arrows will set the 2nd option active for (const _ of [5, 4, 3, 2, 1]) { fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); await fixture.whenStable(); } // Expect to show the top of the 2nd option at the top of the panel. // It is offset by 56, because there's a group label above it plus the panel padding. expect(container.scrollTop) .withContext('Expected panel to scroll up when option is above panel.') .toBe(104); }); it('should scroll back to the top when reaching the first option with preceding group label', async () => { const fixture = createComponent(AutocompleteWithGroups); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); expect(container.scrollTop).withContext('Expected the panel not to scroll.').toBe(0); // Press the down arrow five times. for (const _ of [1, 2, 3, 4, 5]) { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); } // Press the up arrow five times. for (const _ of [1, 2, 3, 4, 5]) { fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); await fixture.whenStable(); } expect(container.scrollTop).withContext('Expected panel to be scrolled to the top.').toBe(0); }); it('should scroll to active option when group is indirect descendant', () => { const fixture = createComponent(AutocompleteWithIndirectGroups); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); expect(container.scrollTop).withContext('Expected the panel not to scroll.').toBe(0); // Press the down arrow five times. for (const _unused of [1, 2, 3, 4, 5]) { fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); } // <option bottom> - <panel height> + <2x group labels> + panel padding = 128 // 288 - 256 + 96 + 8 = 136 // <option bottom> - <panel height> + <2x group labels> + panel padding = 128 // 288 - 256 + 96 + 8 = 136 expect(container.scrollTop) .withContext('Expected panel to reveal the sixth option.') .toBe(136); }); }); describe('aria', () => { let fixture: ComponentFixture<SimpleAutocomplete>; let input: HTMLInputElement; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); input = fixture.debugElement.query(By.css('input'))!.nativeElement; }); it('should set role of input to combobox', () => { expect(input.getAttribute('role')) .withContext('Expected role of input to be combobox.') .toEqual('combobox'); }); it('should set role of autocomplete panel to listbox', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(panel.getAttribute('role')) .withContext('Expected role of the panel to be listbox.') .toEqual('listbox'); }); it('should point the aria-labelledby of the panel to the field label', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; const labelId = fixture.nativeElement.querySelector('label').id; expect(panel.getAttribute('aria-labelledby')).toBe(labelId); expect(panel.hasAttribute('aria-label')).toBe(false); }); it('should add a custom aria-labelledby to the panel', () => { fixture.componentInstance.ariaLabelledby = 'myLabelId'; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; const labelId = fixture.nativeElement.querySelector('label').id; expect(panel.getAttribute('aria-labelledby')).toBe(`${labelId} myLabelId`); expect(panel.hasAttribute('aria-label')).toBe(false); }); it('should trim aria-labelledby if the input does not have a label', () => { fixture.componentInstance.hasLabel = false; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.ariaLabelledby = 'myLabelId'; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(panel.getAttribute('aria-labelledby')).toBe(`myLabelId`); }); it('should clear aria-labelledby from the panel if an aria-label is set', () => { fixture.componentInstance.ariaLabel = 'My label'; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(panel.getAttribute('aria-label')).toBe('My label'); expect(panel.hasAttribute('aria-labelledby')).toBe(false); }); it('should clear aria-labelledby if the form field does not have a label', () => { fixture.componentInstance.hasLabel = false; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(panel.hasAttribute('aria-labelledby')).toBe(false); }); it('should support setting a custom aria-label', () => { fixture.componentInstance.ariaLabel = 'Custom Label'; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(panel.getAttribute('aria-label')).toEqual('Custom Label'); expect(panel.hasAttribute('aria-labelledby')).toBe(false); }); it('should set aria-autocomplete to list', () => { expect(input.getAttribute('aria-autocomplete')) .withContext('Expected aria-autocomplete attribute to equal list.') .toEqual('list'); }); it('should set aria-activedescendant based on the active option', async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(input.hasAttribute('aria-activedescendant')) .withContext('Expected aria-activedescendant to be absent if no active item.') .toBe(false); const DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); expect(input.getAttribute('aria-activedescendant')) .withContext('Expected aria-activedescendant to match the active item after 1 down arrow.') .toEqual(fixture.componentInstance.options.first.id); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); await fixture.whenStable(); fixture.detectChanges(); expect(input.getAttribute('aria-activedescendant')) .withContext('Expected aria-activedescendant to match the active item after 2 down arrows.') .toEqual(fixture.componentInstance.options.toArray()[1].id); }); it('should set aria-expanded based on whether the panel is open', () => { expect(input.getAttribute('aria-expanded')) .withContext('Expected aria-expanded to be false while panel is closed.') .toBe('false'); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(input.getAttribute('aria-expanded')) .withContext('Expected aria-expanded to be true while panel is open.') .toBe('true'); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); expect(input.getAttribute('aria-expanded')) .withContext('Expected aria-expanded to be false when panel closes again.') .toBe('false'); }); it('should set aria-expanded properly when the panel is hidden', async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(input.getAttribute('aria-expanded')) .withContext('Expected aria-expanded to be true while panel is open.') .toBe('true'); typeInElement(input, 'zz'); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(input.getAttribute('aria-expanded')) .withContext('Expected aria-expanded to be false when panel hides itself.') .toBe('false'); }); it('should set aria-controls based on the attached autocomplete', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panel = fixture.debugElement.query( By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; expect(input.getAttribute('aria-controls')) .withContext('Expected aria-controls to match attached autocomplete.') .toBe(panel.getAttribute('id')); }); it('should not set aria-controls while the autocomplete is closed', () => { expect(input.getAttribute('aria-controls')).toBeFalsy(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(input.getAttribute('aria-controls')).toBeTruthy(); }); it('should restore focus to the input when clicking to select a value', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const option = getOverlayHost(fixture)?.querySelector('mat-option') as HTMLElement; // Focus the option manually since the synthetic click may not do it. option.focus(); option.click(); fixture.detectChanges(); expect(document.activeElement) .withContext('Expected focus to be restored to the input.') .toBe(input); }); it('should remove autocomplete-specific aria attributes when autocomplete is disabled', () => { fixture.componentInstance.autocompleteDisabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(input.getAttribute('role')).toBeFalsy(); expect(input.getAttribute('aria-autocomplete')).toBeFalsy(); expect(input.getAttribute('aria-expanded')).toBeFalsy(); expect(input.getAttribute('aria-owns')).toBeFalsy(); }); }); describe('Fallback positions', () => { it('should use below positioning by default', () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const inputBottom = inputReference.getBoundingClientRect().bottom; const panel = getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!; const panelTop = panel.getBoundingClientRect().top; expect(Math.floor(inputBottom)) .withContext(`Expected panel top to match input bottom by default.`) .toEqual(Math.floor(panelTop)); expect(panel.classList).not.toContain('mat-mdc-autocomplete-panel-above'); }); it('should reposition the panel on scroll', () => { let scrolledSubject = new Subject(); let spacer = document.createElement('div'); let fixture = createComponent(SimpleAutocomplete, [ { provide: ScrollDispatcher, useValue: {scrolled: () => scrolledSubject}, }, ]); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; spacer.style.height = '1000px'; document.body.appendChild(spacer); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); window.scroll(0, 100); scrolledSubject.next(); fixture.detectChanges(); const inputBottom = inputReference.getBoundingClientRect().bottom; const panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; const panelTop = panel.getBoundingClientRect().top; expect(Math.floor(inputBottom)) .withContext('Expected panel top to match input bottom after scrolling.') .toEqual(Math.floor(panelTop)); spacer.remove(); window.scroll(0, 0); }); it('should fall back to above position if panel cannot fit below', () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the autocomplete trigger down so it won't have room to open "below" inputReference.style.bottom = '0'; inputReference.style.position = 'fixed'; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const inputTop = inputReference.getBoundingClientRect().top; const panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; const panelBottom = panel.getBoundingClientRect().bottom; expect(Math.floor(inputTop)) .withContext(`Expected panel to fall back to above position.`) .toEqual(Math.floor(panelBottom)); expect(panel.classList).toContain('mat-mdc-autocomplete-panel-above'); }); it('should allow the panel to expand when the number of results increases', () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the element down so it has a little bit of space, but not enough to render. inputReference.style.bottom = '10px'; inputReference.style.position = 'fixed'; // Type enough to only show one option. typeInElement(inputEl, 'California'); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); let panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; let initialPanelHeight = panel.getBoundingClientRect().height; fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); // Change the text so we get more than one result. clearElement(inputEl); typeInElement(inputEl, 'C'); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; expect(panel.getBoundingClientRect().height).toBeGreaterThan(initialPanelHeight); }); it('should align panel properly when filtering in "above" position', () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); let input = fixture.debugElement.query(By.css('input'))!.nativeElement; let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the autocomplete trigger down so it won't have room to open "below" inputReference.style.bottom = '0'; inputReference.style.position = 'fixed'; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); typeInElement(input, 'f'); fixture.detectChanges(); const inputTop = inputReference.getBoundingClientRect().top; const panel = getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!; const panelBottom = panel.getBoundingClientRect().bottom; expect(Math.floor(inputTop)) .withContext(`Expected panel to stay aligned after filtering.`) .toEqual(Math.floor(panelBottom)); }); it( 'should fall back to above position when requested if options are added while ' + 'the panel is open', async () => { let fixture = createComponent(SimpleAutocomplete); fixture.componentInstance.states = fixture.componentInstance.states.slice(0, 1); fixture.componentInstance.filteredStates = fixture.componentInstance.states.slice(); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the element down so it has a little bit of space, but not enough to render. inputReference.style.bottom = '100px'; inputReference.style.position = 'fixed'; dispatchFakeEvent(inputEl, 'focusin'); fixture.detectChanges(); fixture.detectChanges(); let panel = getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!; let inputRect = inputReference.getBoundingClientRect(); let panelRect = panel.getBoundingClientRect(); expect(Math.floor(panelRect.top)) .withContext(`Expected panel top to be below input before repositioning.`) .toBe(Math.floor(inputRect.bottom)); for (let i = 0; i < 20; i++) { fixture.componentInstance.filteredStates.push({code: 'FK', name: 'Fake State'}); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); } // Request a position update now that there are too many suggestions to fit in the viewport. fixture.componentInstance.trigger.updatePosition(); inputRect = inputReference.getBoundingClientRect(); panelRect = panel.getBoundingClientRect(); expect(Math.floor(panelRect.bottom)) .withContext(`Expected panel to fall back to above position after repositioning.`) .toBe(Math.floor(inputRect.top)); }, ); it('should not throw if a panel reposition is requested while the panel is closed', () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); expect(() => fixture.componentInstance.trigger.updatePosition()).not.toThrow(); }); it('should be able to force below position even if there is not enough space', () => { let fixture = createComponent(SimpleAutocomplete); fixture.componentInstance.position = 'below'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the autocomplete trigger down so it won't have room to open below. inputReference.style.bottom = '0'; inputReference.style.position = 'fixed'; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const inputBottom = inputReference.getBoundingClientRect().bottom; const panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; const panelTop = panel.getBoundingClientRect().top; expect(Math.floor(inputBottom)) .withContext('Expected panel to be below the input.') .toEqual(Math.floor(panelTop)); expect(panel.classList).not.toContain('mat-mdc-autocomplete-panel-above'); }); it('should be able to force above position even if there is not enough space', () => { let fixture = createComponent(SimpleAutocomplete); fixture.componentInstance.position = 'above'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; // Push the autocomplete trigger up so it won't have room to open above. inputReference.style.top = '0'; inputReference.style.position = 'fixed'; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const inputTop = inputReference.getBoundingClientRect().top; const panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; const panelBottom = panel.getBoundingClientRect().bottom; expect(Math.floor(inputTop)) .withContext('Expected panel to be above the input.') .toEqual(Math.floor(panelBottom)); expect(panel.classList).toContain('mat-mdc-autocomplete-panel-above'); }); it('should handle the position being changed after the first open', async () => { let fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); let inputReference = fixture.debugElement.query(By.css('.mdc-text-field'))!.nativeElement; let openPanel = async () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); }; // Push the autocomplete trigger down so it won't have room to open below. inputReference.style.bottom = '0'; inputReference.style.position = 'fixed'; await openPanel(); let inputRect = inputReference.getBoundingClientRect(); let panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; let panelRect = panel.getBoundingClientRect(); expect(Math.floor(inputRect.top)) .withContext('Expected panel to be above the input.') .toEqual(Math.floor(panelRect.bottom)); expect(panel.classList).toContain('mat-mdc-autocomplete-panel-above'); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); fixture.componentInstance.position = 'below'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await openPanel(); inputRect = inputReference.getBoundingClientRect(); panel = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!; panelRect = panel.getBoundingClientRect(); expect(Math.floor(inputRect.bottom)) .withContext('Expected panel to be below the input.') .toEqual(Math.floor(panelRect.top)); expect(panel.classList).not.toContain('mat-mdc-autocomplete-panel-above'); }); }); describe('Option selection', () => { let fixture: ComponentFixture<SimpleAutocomplete>; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); }); it('should deselect any other selected option', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); let options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[0].click(); fixture.detectChanges(); fixture.detectChanges(); let componentOptions = fixture.componentInstance.options.toArray(); expect(componentOptions[0].selected) .withContext(`Clicked option should be selected.`) .toBe(true); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); options = getOverlayHost(fixture)!.querySelectorAll('mat-option') as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(componentOptions[0].selected) .withContext(`Previous option should not be selected.`) .toBe(false); expect(componentOptions[1].selected) .withContext(`New Clicked option should be selected.`) .toBe(true); }); it('should call deselect only on the previous selected option', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); let options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[0].click(); fixture.detectChanges(); fixture.detectChanges(); let componentOptions = fixture.componentInstance.options.toArray(); componentOptions.forEach(option => spyOn(option, 'deselect')); expect(componentOptions[0].selected) .withContext(`Clicked option should be selected.`) .toBe(true); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); options = getOverlayHost(fixture)!.querySelectorAll('mat-option') as NodeListOf<HTMLElement>; options[1].click(); fixture.detectChanges(); expect(componentOptions[0].deselect).toHaveBeenCalled(); componentOptions.slice(1).forEach(option => expect(option.deselect).not.toHaveBeenCalled()); }); it('should be able to preselect the first option', () => { fixture.componentInstance.trigger.autocomplete.autoActiveFirstOption = true; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelectorAll('mat-option')[0].classList) .withContext('Expected first option to be highlighted.') .toContain('mat-mdc-option-active'); }); it( 'should skip to the next enabled option if the first one is disabled ' + 'when using `autoActiveFirstOption`', async () => { const testComponent = fixture.componentInstance; testComponent.trigger.autocomplete.autoActiveFirstOption = true; testComponent.states[0].disabled = true; testComponent.states[1].disabled = true; testComponent.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelectorAll('mat-option')[2].classList) .withContext('Expected third option to be highlighted.') .toContain('mat-mdc-option-active'); }, ); it('should not activate any option if all options are disabled', () => { const testComponent = fixture.componentInstance; testComponent.trigger.autocomplete.autoActiveFirstOption = true; for (const state of testComponent.states) { state.disabled = true; } testComponent.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); const selectedOptions = getOverlayHost(fixture)!.querySelectorAll( 'mat-option.mat-mdc-option-active', ); expect(selectedOptions.length).withContext('expected no options to be active').toBe(0); }); it('should remove aria-activedescendant when panel is closed with autoActiveFirstOption', () => { const input: HTMLElement = fixture.nativeElement.querySelector('input'); expect(input.hasAttribute('aria-activedescendant')) .withContext('Expected no active descendant on init.') .toBe(false); fixture.componentInstance.trigger.autocomplete.autoActiveFirstOption = true; fixture.changeDetectorRef.markForCheck(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(input.getAttribute('aria-activedescendant')) .withContext('Expected active descendant while open.') .toBeTruthy(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); expect(input.hasAttribute('aria-activedescendant')) .withContext('Expected no active descendant when closed.') .toBe(false); }); it('should be able to preselect the first option when the floating label is disabled', () => { // TODO(crisbeto): this test likely doesn't make sense anymore. fixture.componentInstance.floatLabel = 'never' as any; fixture.componentInstance.trigger.autocomplete.autoActiveFirstOption = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); // Note: should not have a detectChanges call here // in order for the test to fail when it's supposed to. expect(getOverlayHost(fixture)!.querySelectorAll('mat-option')[0].classList) .withContext('Expected first option to be highlighted.') .toContain('mat-mdc-option-active'); }); it('should be able to configure preselecting the first option globally', () => { fixture.destroy(); TestBed.resetTestingModule(); fixture = createComponent(SimpleAutocomplete, [ {provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {autoActiveFirstOption: true}}, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(getOverlayHost(fixture)!.querySelectorAll('mat-option')[0].classList) .withContext('Expected first option to be highlighted.') .toContain('mat-mdc-option-active'); }); it('should handle `optionSelections` being accessed too early', () => { fixture.destroy(); fixture = TestBed.createComponent(SimpleAutocomplete); let spy = jasmine.createSpy('option selection spy'); let subscription: Subscription; expect(fixture.componentInstance.trigger.autocomplete).toBeFalsy(); expect(() => { subscription = fixture.componentInstance.trigger.optionSelections.subscribe(spy); }).not.toThrow(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; option.click(); fixture.detectChanges(); expect(spy).toHaveBeenCalledWith(jasmine.any(MatOptionSelectionChange)); subscription!.unsubscribe(); }); it('should emit to `optionSelections` if the list of options changes', async () => { const spy = jasmine.createSpy('option selection spy'); const subscription = fixture.componentInstance.trigger.optionSelections.subscribe(spy); const openAndSelectFirstOption = async () => { fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); (getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement).click(); fixture.detectChanges(); }; fixture.componentInstance.states = [{code: 'OR', name: 'Oregon'}]; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await openAndSelectFirstOption(); expect(spy).toHaveBeenCalledTimes(1); fixture.componentInstance.states = [{code: 'WV', name: 'West Virginia'}]; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await openAndSelectFirstOption(); expect(spy).toHaveBeenCalledTimes(2); subscription!.unsubscribe(); }); it('should reposition the panel when the amount of options changes', async () => { const flushPosition = async () => { fixture.detectChanges(); await wait(0); fixture.detectChanges(); await wait(0); fixture.detectChanges(); // Safari seems to require an extra round that other browsers don't. await wait(0); fixture.detectChanges(); await wait(0); fixture.detectChanges(); await wait(0); fixture.detectChanges(); await wait(0); fixture.detectChanges(); }; await flushPosition(); let formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; let inputReference = formField.querySelector('.mdc-text-field'); let input = inputReference.querySelector('input'); formField.style.bottom = '100px'; formField.style.position = 'fixed'; typeInElement(input, 'Cali'); await flushPosition(); const inputBottom = inputReference.getBoundingClientRect().bottom; const panel = getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!; const panelTop = panel.getBoundingClientRect().top; expect(Math.floor(inputBottom)) .withContext(`Expected panel top to match input bottom when there is only one option.`) .toBe(Math.floor(panelTop)); clearElement(input); await flushPosition(); const inputTop = inputReference.getBoundingClientRect().top; const panelBottom = panel.getBoundingClientRect().bottom; expect(Math.floor(inputTop)) .withContext(`Expected panel switch to the above position if the options no longer fit.`) .toBe(Math.floor(panelBottom)); }); it('should clear the selected option when the input value is cleared', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; const optionInstance = fixture.componentInstance.options.first; const spy = jasmine.createSpy('selectionChange spy'); option.click(); fixture.detectChanges(); expect(input.value).toBe('Alabama'); expect(optionInstance.selected).toBe(true); const subscription = optionInstance.onSelectionChange.subscribe(spy); clearElement(input); fixture.detectChanges(); expect(input.value).toBe(''); expect(optionInstance.selected).toBe(false); expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); }); it('should accept the user selection if they click on an option while selection is required', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; const spy = jasmine.createSpy('optionSelected spy'); const subscription = trigger.optionSelections.subscribe(spy); options[5].click(); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('Oregon'); expect(stateCtrl.value).toEqual({code: 'OR', name: 'Oregon'}); expect(spy).toHaveBeenCalledTimes(1); subscription.unsubscribe(); }); it('should accept the user selection if they press enter on an option while selection is required', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; const spy = jasmine.createSpy('optionSelected spy'); const subscription = trigger.optionSelections.subscribe(spy); dispatchKeyboardEvent(options[5], 'keydown', ENTER); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('Oregon'); expect(stateCtrl.value).toEqual({code: 'OR', name: 'Oregon'}); expect(spy).toHaveBeenCalledTimes(1); subscription.unsubscribe(); }); it('should accept the user selection if autoSelectActiveOption is enabled', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; trigger.autocomplete.autoSelectActiveOption = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); for (let i = 0; i < 5; i++) { dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); } dispatchFakeEvent(document, 'click'); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('New York'); expect(stateCtrl.value).toEqual({code: 'NY', name: 'New York'}); }); it('should clear the value if selection is required and the user interacted with the panel without selecting anything', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const spy = jasmine.createSpy('optionSelected spy'); const subscription = trigger.optionSelections.subscribe(spy); input.value = 'Cali'; dispatchKeyboardEvent(input, 'input'); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('Cali'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); expect(spy).not.toHaveBeenCalled(); dispatchFakeEvent(document, 'click'); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe(''); expect(stateCtrl.value).toBe(null); expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); }); it('should preserve the value if a selection is required, but the user opened and closed the panel without interacting with it', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const spy = jasmine.createSpy('optionSelected spy'); const subscription = trigger.optionSelections.subscribe(spy); dispatchFakeEvent(document, 'click'); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); }); it('should preserve the value if a selection is required, and there are no options', async () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger, states} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); stateCtrl.setValue(states[1]); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); fixture.componentInstance.states = fixture.componentInstance.filteredStates = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const spy = jasmine.createSpy('optionSelected spy'); const subscription = trigger.optionSelections.subscribe(spy); dispatchFakeEvent(document, 'click'); fixture.detectChanges(); await fixture.whenStable(); expect(input.value).toBe('California'); expect(stateCtrl.value).toEqual({code: 'CA', name: 'California'}); expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); }); it('should clear the value if requireSelection is enabled and the user edits the input before clicking away', () => { const input = fixture.nativeElement.querySelector('input'); const {stateCtrl, trigger} = fixture.componentInstance; fixture.componentInstance.requireSelection = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); // Simulate opening the input and clicking the first option. trigger.openPanel(); fixture.detectChanges(); (getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement).click(); fixture.detectChanges(); expect(trigger.panelOpen).toBe(false); expect(input.value).toBe('Alabama'); expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); // Simulate pressing backspace while focus is still on the input. dispatchFakeEvent(input, 'keydown'); input.value = 'Alabam'; fixture.detectChanges(); dispatchFakeEvent(input, 'input'); fixture.detectChanges(); expect(trigger.panelOpen).toBe(true); expect(input.value).toBe('Alabam'); expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); // Simulate clicking away. input.blur(); dispatchFakeEvent(document, 'click'); fixture.detectChanges(); expect(trigger.panelOpen).toBe(false); expect(input.value).toBe(''); expect(stateCtrl.value).toBe(null); }); }); describe('panel closing', () => { let fixture: ComponentFixture<SimpleAutocomplete>; let input: HTMLInputElement; let trigger: MatAutocompleteTrigger; let closingActionSpy: jasmine.Spy; let closingActionsSub: Subscription; beforeEach(async () => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); trigger = fixture.componentInstance.trigger; closingActionSpy = jasmine.createSpy('closing action listener'); closingActionsSub = trigger.panelClosingActions.subscribe(closingActionSpy); input = fixture.debugElement.query(By.css('input'))!.nativeElement; fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); }); afterEach(() => { closingActionsSub.unsubscribe(); }); it('should emit panel close event when clicking away', () => { expect(closingActionSpy).not.toHaveBeenCalled(); dispatchFakeEvent(document, 'click'); expect(closingActionSpy).toHaveBeenCalledWith(null); }); it('should emit panel close event when tabbing out', () => { const tabEvent = createKeyboardEvent('keydown', TAB); input.focus(); expect(closingActionSpy).not.toHaveBeenCalled(); trigger._handleKeydown(tabEvent); expect(closingActionSpy).toHaveBeenCalledWith(null); }); it('should not emit when tabbing away from a closed panel', () => { const tabEvent = createKeyboardEvent('keydown', TAB); input.focus(); trigger._handleKeydown(tabEvent); // Ensure that it emitted once while the panel was open. expect(closingActionSpy).toHaveBeenCalledTimes(1); trigger._handleKeydown(tabEvent); // Ensure that it didn't emit again when tabbing out again. expect(closingActionSpy).toHaveBeenCalledTimes(1); }); it('should emit panel close event when selecting an option', () => { const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; expect(closingActionSpy).not.toHaveBeenCalled(); option.click(); expect(closingActionSpy).toHaveBeenCalledWith(jasmine.any(MatOptionSelectionChange)); }); it('should close the panel when pressing escape', () => { expect(closingActionSpy).not.toHaveBeenCalled(); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); expect(closingActionSpy).toHaveBeenCalledWith(null); }); // TODO(mmalerba): This test previously only passed because it wasn't properly flushed. // We should figure out if this is indeed the desired behavior, and if so fix the // implementation. // tslint:disable-next-line:ban xit('should not prevent escape key propagation when there are no options', () => { fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const event = createKeyboardEvent('keydown', ESCAPE); spyOn(event, 'stopPropagation').and.callThrough(); dispatchEvent(document.body, event); fixture.detectChanges(); expect(event.stopPropagation).not.toHaveBeenCalled(); }); }); describe('without matInput', () => { let fixture: ComponentFixture<AutocompleteWithNativeInput>; beforeEach(() => { fixture = createComponent(AutocompleteWithNativeInput); fixture.detectChanges(); }); it('should not throw when clicking outside', async () => { dispatchFakeEvent(fixture.debugElement.query(By.css('input'))!.nativeElement, 'focus'); fixture.detectChanges(); await fixture.whenStable(); expect(() => dispatchFakeEvent(document, 'click')).not.toThrow(); }); }); describe('with panel classes in the default options', () => { it('should apply them if provided as string', () => { const fixture = createComponent(SimpleAutocomplete, [ {provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {overlayPanelClass: 'default1'}}, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panelClassList = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!.classList; expect(panelClassList).toContain('default1'); }); it('should apply them if provided as array', () => { const fixture = createComponent(SimpleAutocomplete, [ { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {overlayPanelClass: ['default1', 'default2']}, }, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const panelClassList = getOverlayHost(fixture)!.querySelector('.cdk-overlay-pane')!.classList; expect(panelClassList).toContain('default1'); expect(panelClassList).toContain('default2'); }); }); describe('with backdrop in options', () => { it('should not contain backdrop by default', async () => { const fixture = createComponent(SimpleAutocomplete, []); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); expect(getBackdrop(fixture)).toBeFalsy(); }); it('should be able to add the backdrop using hasBackdrop option', async () => { const fixture = createComponent(SimpleAutocomplete, [ { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {hasBackdrop: true}, }, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); expect(getBackdrop(fixture)).toBeTruthy(); }); }); describe('with hasBackdrop and backdropClass in options', () => { it('should be able to configure custom backdrop class', async () => { const fixture = createComponent(SimpleAutocomplete, [ { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {backdropClass: 'my-custom-backdrop-class', hasBackdrop: true}, }, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); await fixture.whenStable(); const backdrop = getBackdrop(fixture); expect(backdrop?.classList).toContain('my-custom-backdrop-class'); }); }); describe('misc', () => { it('should allow basic use without any forms directives', () => { expect(() => { const fixture = createComponent(AutocompleteWithoutForms); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; typeInElement(input, 'd'); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; expect(options.length).toBe(1); }).not.toThrowError(); }); it('should display an empty input when the value is undefined with ngModel', () => { const fixture = createComponent(AutocompleteWithNgModel); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('input'))!.nativeElement.value).toBe(''); }); it('should display the number when the selected option is the number zero', async () => { const fixture = createComponent(AutocompleteWithNumbers); fixture.componentInstance.selectedNumber = 0; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await wait(100); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('input'))!.nativeElement.value).toBe('0'); }); it('should work when input is wrapped in ngIf', () => { const fixture = createComponent(NgIfAutocomplete); fixture.detectChanges(); dispatchFakeEvent(fixture.debugElement.query(By.css('input'))!.nativeElement, 'focusin'); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) .withContext(`Expected panel state to read open when input is focused.`) .toBe(true); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when input is focused.`) .toContain('One'); expect(getOverlayHost(fixture)?.textContent) .withContext(`Expected panel to display when input is focused.`) .toContain('Two'); }); it('should filter properly with ngIf after setting the active item', () => { const fixture = createComponent(NgIfAutocomplete); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; typeInElement(input, 'o'); fixture.detectChanges(); expect(fixture.componentInstance.matOptions.length).toBe(2); }); it('should throw if the user attempts to open the panel too early', () => { const fixture = createComponent(AutocompleteWithoutPanel); fixture.detectChanges(); expect(() => { fixture.componentInstance.trigger.openPanel(); }).toThrow(getMatAutocompleteMissingPanelError()); }); it('should not throw on init, even if the panel is not defined', async () => { const fixture = createComponent(AutocompleteWithoutPanel); fixture.componentInstance.control.setValue('Something'); fixture.detectChanges(); await fixture.whenStable(); }); it('should transfer the mat-autocomplete classes to the panel element', async () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); await fixture.whenStable(); fixture.detectChanges(); const autocomplete = fixture.debugElement.nativeElement.querySelector('mat-autocomplete'); const panel = getOverlayHost(fixture)!.querySelector('.mat-mdc-autocomplete-panel')!; expect(autocomplete.classList).not.toContain('class-one'); expect(autocomplete.classList).not.toContain('class-two'); expect(panel.classList).toContain('class-one'); expect(panel.classList).toContain('class-two'); }); it('should remove old classes when the panel class changes', async () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); await fixture.whenStable(); fixture.detectChanges(); const classList = getOverlayHost(fixture)!.querySelector( '.mat-mdc-autocomplete-panel', )!.classList; expect(classList).toContain('mat-mdc-autocomplete-visible'); expect(classList).toContain('class-one'); expect(classList).toContain('class-two'); fixture.componentInstance.panelClass = 'class-three class-four'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(classList).not.toContain('class-one'); expect(classList).not.toContain('class-two'); expect(classList).toContain('mat-mdc-autocomplete-visible'); expect(classList).toContain('class-three'); expect(classList).toContain('class-four'); }); it('should reset correctly when closed programmatically', () => { const scrolledSubject = new Subject(); const fixture = createComponent(SimpleAutocomplete, [ { provide: ScrollDispatcher, useValue: {scrolled: () => scrolledSubject}, }, { provide: MAT_AUTOCOMPLETE_SCROLL_STRATEGY, useFactory: () => () => createCloseScrollStrategy(TestBed.inject(Injector)), }, ]); fixture.detectChanges(); const trigger = fixture.componentInstance.trigger; trigger.openPanel(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); scrolledSubject.next(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); }); it('should handle autocomplete being attached to number inputs', () => { const fixture = createComponent(AutocompleteWithNumberInputAndNgModel); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; typeInElement(input, '1337'); fixture.detectChanges(); expect(fixture.componentInstance.selectedValue).toBe(1337); }); it('should not focus the option when DOWN key is pressed', () => { const fixture = createComponent(SimpleAutocomplete); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; fixture.detectChanges(); const spy = spyOn(console, 'error'); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); // Note: for some reason the error here gets logged using console.error, rather than being // thrown, hence why we use a spy to assert against it, rather than `.not.toThrow`. expect(spy).not.toHaveBeenCalled(); }); }); it('should not throw errors when closing without options', () => { const fixture = createComponent(AutocompleteWithoutOptions); fixture.detectChanges(); const trigger = fixture.componentInstance.trigger; trigger.openPanel(); fixture.detectChanges(); fixture.destroy(); expect(() => trigger.closePanel()).not.toThrow(); }); describe('automatically selecting the active option', () => { let fixture: ComponentFixture<SimpleAutocomplete>; beforeEach(() => { fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); fixture.componentInstance.trigger.autocomplete.autoSelectActiveOption = true; fixture.changeDetectorRef.markForCheck(); }); it( 'should update the input value as the user is navigating, without changing the model ' + 'value or closing the panel', async () => { const {trigger, stateCtrl, closedSpy} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); expect(trigger.panelOpen).toBe(true); expect(closedSpy).not.toHaveBeenCalled(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); expect(trigger.panelOpen).toBe(true); expect(closedSpy).not.toHaveBeenCalled(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('California'); expect(trigger.panelOpen).toBe(true); expect(closedSpy).not.toHaveBeenCalled(); }, ); it('should revert back to the last typed value if the user presses escape', () => { const {trigger, stateCtrl, closedSpy} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); typeInElement(input, 'al'); fixture.detectChanges(); expect(stateCtrl.value).toBe('al'); expect(input.value).toBe('al'); expect(trigger.panelOpen).toBe(true); expect(closedSpy).not.toHaveBeenCalled(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBe('al'); expect(input.value).toBe('Alabama'); expect(trigger.panelOpen).toBe(true); expect(closedSpy).not.toHaveBeenCalled(); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); fixture.detectChanges(); expect(stateCtrl.value).toBe('al'); expect(input.value).toBe('al'); expect(trigger.panelOpen).toBe(false); expect(closedSpy).toHaveBeenCalledTimes(1); }); it('should emit a closed event if no option is displayed', async () => { const {openedSpy, closedSpy} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); typeInElement(input, 'Alabama'); // Valid option fixture.detectChanges(); await fixture.whenStable(); expect(openedSpy).toHaveBeenCalledTimes(1); expect(closedSpy).toHaveBeenCalledTimes(0); typeInElement(input, '_x'); // Invalidate option to 'Alabama_x' fixture.detectChanges(); await wait(100); // Yield to allow panel to close fixture.detectChanges(); expect(openedSpy).toHaveBeenCalledTimes(1); expect(closedSpy).toHaveBeenCalledTimes(1); }); it( 'should clear the input if the user presses escape while there was a pending ' + 'auto selection and there is no previous value', async () => { const {trigger, stateCtrl} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); }, ); it('should propagate the auto-selected value if the user clicks away', () => { const {trigger, stateCtrl} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); dispatchFakeEvent(document, 'click'); fixture.detectChanges(); expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); expect(input.value).toBe('Alabama'); }); it('should propagate the auto-selected value if the user tabs away', () => { const {trigger, stateCtrl} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); dispatchKeyboardEvent(input, 'keydown', TAB); fixture.detectChanges(); expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); expect(input.value).toBe('Alabama'); }); it('should propagate the auto-selected value if the user presses enter on it', () => { const {trigger, stateCtrl} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); dispatchKeyboardEvent(input, 'keydown', ENTER); fixture.detectChanges(); expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); expect(input.value).toBe('Alabama'); }); it('should allow the user to click on an option different from the auto-selected one', () => { const {trigger, stateCtrl} = fixture.componentInstance; const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); trigger.openPanel(); fixture.detectChanges(); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBeFalsy(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(stateCtrl.value).toBeFalsy(); expect(input.value).toBe('Alabama'); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; options[2].click(); fixture.detectChanges(); expect(stateCtrl.value).toEqual({code: 'FL', name: 'Florida'}); expect(input.value).toBe('Florida'); }); }); it('should have correct width when opened', () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; // Firefox, Edge return a decimal value for width, so we need to parse and round it to verify expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(300); widthFixture.componentInstance.trigger.closePanel(); widthFixture.detectChanges(); widthFixture.componentInstance.width = 500; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); // Firefox, Edge return a decimal value for width, so we need to parse and round it to verify expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(500); }); it('should update the width while the panel is open', () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; const input = widthFixture.debugElement.query(By.css('input'))!.nativeElement; expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(300); widthFixture.componentInstance.width = 500; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); input.focus(); dispatchFakeEvent(input, 'input'); widthFixture.detectChanges(); expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(500); }); it('should not reopen a closed autocomplete when returning to a blurred tab', () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); const trigger = fixture.componentInstance.trigger; const input = fixture.debugElement.query(By.css('input'))!.nativeElement; input.focus(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be open.').toBe(true); trigger.closePanel(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to be closed.').toBe(false); // Simulate the user going to a different tab. dispatchFakeEvent(window, 'blur'); input.blur(); fixture.detectChanges(); // Simulate the user coming back. dispatchFakeEvent(window, 'focus'); input.focus(); fixture.detectChanges(); expect(trigger.panelOpen).withContext('Expected panel to remain closed.').toBe(false); }); it('should update the panel width if the window is resized', async () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(300); widthFixture.componentInstance.width = 400; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); dispatchFakeEvent(window, 'resize'); await wait(50); expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(400); }); it('should have panel width match host width by default', () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(300); }); it('should have panel width set to string value', () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.autocomplete.panelWidth = 'auto'; widthFixture.changeDetectorRef.markForCheck(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; expect(overlayPane.style.width).toBe('auto'); }); it('should have panel width set to number value', () => { const widthFixture = createComponent(SimpleAutocomplete); widthFixture.componentInstance.width = 300; widthFixture.changeDetectorRef.markForCheck(); widthFixture.detectChanges(); widthFixture.componentInstance.trigger.autocomplete.panelWidth = 400; widthFixture.changeDetectorRef.markForCheck(); widthFixture.componentInstance.trigger.openPanel(); widthFixture.detectChanges(); const overlayPane = getOverlayHost(widthFixture)!.querySelector( '.cdk-overlay-pane', ) as HTMLElement; expect(Math.ceil(parseFloat(overlayPane.style.width as string))).toBe(400); }); it('should show the panel when the options are initialized later within a component with OnPush change detection', async () => { let fixture = createComponent(AutocompleteWithOnPushDelay); fixture.detectChanges(); await fixture.whenStable(); dispatchFakeEvent(fixture.debugElement.query(By.css('input'))!.nativeElement, 'focusin'); await wait(1500); // Wait longer than the 1000ms timeout in the component await fixture.whenStable(); let panel = getOverlayHost(fixture)!.querySelector( '.mat-mdc-autocomplete-panel', ) as HTMLElement; let visibleClass = 'mat-mdc-autocomplete-visible'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(panel.classList).withContext(`Expected panel to be visible.`).toContain(visibleClass); }); it('should emit an event when an option is selected', () => { let fixture = createComponent(AutocompleteWithSelectEvent); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); let options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; let spy = fixture.componentInstance.optionSelected; options[1].click(); fixture.detectChanges(); expect(spy).toHaveBeenCalledTimes(1); let event = spy.calls.mostRecent().args[0] as MatAutocompleteSelectedEvent; expect(event.source).toBe(fixture.componentInstance.autocomplete); expect(event.option.value).toBe('Washington'); }); it('should refocus the input after the selection event is emitted', () => { const events: string[] = []; const fixture = createComponent(AutocompleteWithSelectEvent); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; spyOn(input, 'focus').and.callFake(() => events.push('focus')); fixture.componentInstance.optionSelected.and.callFake(() => events.push('select')); options[1].click(); fixture.detectChanges(); expect(events).toEqual(['select', 'focus']); }); it('should emit an event when a newly-added option is selected', async () => { let fixture = createComponent(AutocompleteWithSelectEvent); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); await fixture.whenStable(); fixture.detectChanges(); fixture.componentInstance.states.push('Puerto Rico'); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); let options = getOverlayHost(fixture)!.querySelectorAll( 'mat-option', ) as NodeListOf<HTMLElement>; let spy = fixture.componentInstance.optionSelected; options[3].click(); await fixture.whenStable(); fixture.detectChanges(); expect(spy).toHaveBeenCalledTimes(1); let event = spy.calls.mostRecent().args[0] as MatAutocompleteSelectedEvent; expect(event.source).toBe(fixture.componentInstance.autocomplete); expect(event.option.value).toBe('Puerto Rico'); }); it('should emit an event when an option is activated', () => { const fixture = createComponent(AutocompleteWithActivatedEvent); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); const spy = fixture.componentInstance.optionActivated; const autocomplete = fixture.componentInstance.autocomplete; const options = fixture.componentInstance.options.toArray(); expect(spy).not.toHaveBeenCalled(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(spy.calls.mostRecent().args[0]).toEqual({source: autocomplete, option: options[0]}); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(spy.calls.mostRecent().args[0]).toEqual({source: autocomplete, option: options[1]}); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(spy.calls.mostRecent().args[0]).toEqual({source: autocomplete, option: options[2]}); }); it('should not emit the optionActivated event when the active option is reset', () => { const fixture = createComponent(AutocompleteWithActivatedEvent); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); const spy = fixture.componentInstance.optionActivated; expect(spy).not.toHaveBeenCalled(); dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(spy).toHaveBeenCalledTimes(1); dispatchKeyboardEvent(input, 'keydown', ESCAPE); fixture.detectChanges(); expect(spy).toHaveBeenCalledTimes(1); }); it('should be able to set a custom panel connection element', () => { const fixture = createComponent(AutocompleteWithDifferentOrigin); fixture.detectChanges(); fixture.componentInstance.connectedTo = fixture.componentInstance.alternateOrigin; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const overlayRect = getOverlayHost(fixture)! .querySelector('.cdk-overlay-pane')! .getBoundingClientRect(); const originRect = fixture.nativeElement.querySelector('.origin').getBoundingClientRect(); expect(Math.floor(overlayRect.top)) .withContext('Expected autocomplete panel to align with the bottom of the new origin.') .toBe(Math.floor(originRect.bottom)); }); it('should be able to change the origin after the panel has been opened', () => { const fixture = createComponent(AutocompleteWithDifferentOrigin); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); fixture.componentInstance.trigger.closePanel(); fixture.detectChanges(); fixture.componentInstance.connectedTo = fixture.componentInstance.alternateOrigin; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const overlayRect = getOverlayHost(fixture)! .querySelector('.cdk-overlay-pane')! .getBoundingClientRect(); const originRect = fixture.nativeElement.querySelector('.origin').getBoundingClientRect(); expect(Math.floor(overlayRect.top)) .withContext('Expected autocomplete panel to align with the bottom of the new origin.') .toBe(Math.floor(originRect.bottom)); }); it('should be able to re-type the same value when it is reset while open', async () => { const fixture = createComponent(SimpleAutocomplete); fixture.detectChanges(); const input = fixture.debugElement.query(By.css('input'))!.nativeElement; const formControl = fixture.componentInstance.stateCtrl; typeInElement(input, 'Cal'); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(formControl.value) .withContext('Expected initial value to be propagated to model') .toBe('Cal'); formControl.setValue(''); fixture.detectChanges(); expect(input.value).withContext('Expected input value to reset when model is reset').toBe(''); typeInElement(input, 'Cal'); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(formControl.value) .withContext('Expected new value to be propagated to model') .toBe('Cal'); }); it('should not close when clicking inside alternate origin', () => { const fixture = createComponent(AutocompleteWithDifferentOrigin); fixture.detectChanges(); fixture.componentInstance.connectedTo = fixture.componentInstance.alternateOrigin; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen).toBe(true); const origin = fixture.nativeElement.querySelector('.origin'); origin.click(); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen).toBe(true); }); describe('a11y', () => { it('should display checkmark for selection by default', () => { const fixture = createComponent(AutocompleteWithNgModel); fixture.componentInstance.selectedState = 'New York'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); dispatchFakeEvent(document.querySelector('mat-option')!, 'click'); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const selectedOption = document.querySelector('mat-option[aria-selected="true"]'); expect(selectedOption).withContext('Expected an option to be selected.').not.toBeNull(); expect(selectedOption?.querySelector('.mat-pseudo-checkbox.mat-pseudo-checkbox-minimal')) .withContext( 'Expected selection option to have a pseudo-checkbox with "minimal" appearance.', ) .toBeTruthy(); }); }); describe('with token to hide single selection indicator', () => { it('should not display checkmark', () => { const defaultOptions: MatAutocompleteDefaultOptions = { hideSingleSelectionIndicator: true, }; const fixture = createComponent(AutocompleteWithNgModel, [ {provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: defaultOptions}, ]); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); dispatchFakeEvent(document.querySelector('mat-option')!, 'click'); fixture.detectChanges(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); const selectedOption = document.querySelector('mat-option[aria-selected="true"]'); expect(selectedOption).withContext('Expected an option to be selected.').not.toBeNull(); expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(0); }); }); }); const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` <mat-form-field [floatLabel]="floatLabel" [style.width.px]="width" [color]="theme"> @if (hasLabel) { <mat-label>State</mat-label> } <input matInput placeholder="State" [matAutocomplete]="auto" [matAutocompletePosition]="position" [matAutocompleteDisabled]="autocompleteDisabled" [formControl]="stateCtrl"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete" [class]="panelClass" [displayWith]="displayFn" [disableRipple]="disableRipple" [requireSelection]="requireSelection" [aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby" (opened)="openedSpy()" (closed)="closedSpy()"> @for (state of filteredStates; track state) { <mat-option [value]="state" [style.height.px]="state.height" [disabled]="state.disabled"> <span>{{ state.code }}: {{ state.name }}</span> </mat-option> } </mat-autocomplete> `; @Component({ template: SIMPLE_AUTOCOMPLETE_TEMPLATE, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, ReactiveFormsModule, ], changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleAutocomplete implements OnDestroy { stateCtrl = new FormControl<{name: string; code: string} | string | null>(null); filteredStates!: any[]; valueSub!: Subscription; floatLabel: FloatLabelType = 'auto'; position: 'auto' | 'above' | 'below' = 'auto'; width!: number; disableRipple = false; autocompleteDisabled = false; hasLabel = true; requireSelection = false; ariaLabel!: string; ariaLabelledby!: string; panelClass = 'class-one class-two'; theme!: ThemePalette; openedSpy = jasmine.createSpy('autocomplete opened spy'); closedSpy = jasmine.createSpy('autocomplete closed spy'); @ViewChild(MatAutocompleteTrigger, {static: true}) trigger!: MatAutocompleteTrigger; @ViewChild(MatAutocomplete) panel!: MatAutocomplete; @ViewChild(MatFormField) formField!: MatFormField; @ViewChildren(MatOption) options!: QueryList<MatOption>; states: {code: string; name: string; height?: number; disabled?: boolean}[] = [ {code: 'AL', name: 'Alabama'}, {code: 'CA', name: 'California'}, {code: 'FL', name: 'Florida'}, {code: 'KS', name: 'Kansas'}, {code: 'MA', name: 'Massachusetts'}, {code: 'NY', name: 'New York'}, {code: 'OR', name: 'Oregon'}, {code: 'PA', name: 'Pennsylvania'}, {code: 'TN', name: 'Tennessee'}, {code: 'VA', name: 'Virginia'}, {code: 'WY', name: 'Wyoming'}, ]; constructor() { this.filteredStates = this.states; this.valueSub = this.stateCtrl.valueChanges.subscribe(val => { this.filteredStates = val ? this.states.filter(s => s.name.match(new RegExp(val as string, 'gi'))) : this.states; }); } displayFn(value: any): string { return value ? value.name : value; } ngOnDestroy() { this.valueSub.unsubscribe(); } } @Component({ template: SIMPLE_AUTOCOMPLETE_TEMPLATE, encapsulation: ViewEncapsulation.ShadowDom, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, ReactiveFormsModule, ], changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleAutocompleteShadowDom extends SimpleAutocomplete {} @Component({ template: ` @if (isVisible) { <mat-form-field> <input matInput placeholder="Choose" [matAutocomplete]="auto" [formControl]="optionCtrl"> </mat-form-field> } <mat-autocomplete #auto="matAutocomplete"> @for (option of filteredOptions | async; track option) { <mat-option [value]="option"> {{option}} </mat-option> } </mat-autocomplete> `, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, ReactiveFormsModule, AsyncPipe, ], changeDetection: ChangeDetectionStrategy.Eager, }) class NgIfAutocomplete { optionCtrl = new FormControl(''); filteredOptions!: Observable<any>; isVisible = true; options = ['One', 'Two', 'Three']; @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; @ViewChildren(MatOption) matOptions!: QueryList<MatOption>; constructor() { this.filteredOptions = this.optionCtrl.valueChanges.pipe( startWith(null), map(val => { return val ? this.options.filter(option => new RegExp(val, 'gi').test(option)) : this.options.slice(); }), ); } } @Component({ template: ` <mat-form-field> <input matInput placeholder="State" [matAutocomplete]="auto" (input)="onInput($event.target.value)"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (state of filteredStates; track state) { <mat-option [value]="state"> <span> {{ state }} </span> </mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithoutForms { filteredStates!: any[]; states = ['Alabama', 'California', 'Florida']; constructor() { this.filteredStates = this.states.slice(); } onInput(value: any) { this.filteredStates = this.states.filter(s => new RegExp(value, 'gi').test(s)); } } @Component({ template: ` <mat-form-field> <input matInput placeholder="State" [matAutocomplete]="auto" [(ngModel)]="selectedState" (ngModelChange)="onInput($event)"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (state of filteredStates; track state) { <mat-option [value]="state"> <span>{{ state }}</span> </mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithNgModel { filteredStates!: any[]; selectedState!: string; states = ['New York', 'Washington', 'Oregon']; @ViewChild(MatAutocompleteTrigger, {static: true}) trigger!: MatAutocompleteTrigger; constructor() { this.filteredStates = this.states.slice(); } onInput(value: any) { this.filteredStates = this.states.filter(s => new RegExp(value, 'gi').test(s)); } } @Component({ template: ` <mat-form-field> <input matInput placeholder="Number" [matAutocomplete]="auto" [(ngModel)]="selectedNumber"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (number of numbers; track number) { <mat-option [value]="number"> <span>{{ number }}</span> </mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithNumbers { selectedNumber!: number; numbers = [0, 1, 2]; } @Component({ template: ` <mat-form-field> <input type="text" matInput [matAutocomplete]="auto"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (option of options(); track option) { <mat-option [value]="option">{{ option }}</mat-option> } </mat-autocomplete> `, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, ReactiveFormsModule, ], }) class AutocompleteWithOnPushDelay implements OnInit { @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; options = signal<string[]>([]); ngOnInit() { setTimeout(() => { this.options.set(['One']); }, 1000); } } @Component({ template: ` <input placeholder="Choose" [matAutocomplete]="auto" [formControl]="optionCtrl"> <mat-autocomplete #auto="matAutocomplete"> @for (option of filteredOptions | async; track option) { <mat-option [value]="option">{{option}}</mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, ReactiveFormsModule, AsyncPipe], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithNativeInput { optionCtrl = new FormControl(''); filteredOptions!: Observable<any>; options = ['En', 'To', 'Tre', 'Fire', 'Fem']; @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; @ViewChildren(MatOption) matOptions!: QueryList<MatOption>; constructor() { this.filteredOptions = this.optionCtrl.valueChanges.pipe( startWith(null), map(val => { return val ? this.options.filter(option => new RegExp(val, 'gi').test(option)) : this.options.slice(); }), ); } } @Component({ template: `<input placeholder="Choose" [matAutocomplete]="null!" [formControl]="control">`, imports: [MatAutocompleteTrigger, ReactiveFormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithoutPanel { @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; control = new FormControl(''); } @Component({ template: ` <mat-form-field> <input matInput placeholder="State" [matAutocomplete]="auto" [(ngModel)]="selectedState"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (group of stateGroups; track group) { <mat-optgroup [label]="group.title"> @for (state of group.states; track state) { <mat-option [value]="state"> <span>{{ state }}</span> </mat-option> } </mat-optgroup> } </mat-autocomplete> `, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOptgroup, MatOption, MatInputModule, FormsModule, ], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithGroups { @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; selectedState!: string; stateGroups = [ { title: 'One', states: ['Alabama', 'California', 'Florida', 'Oregon'], }, { title: 'Two', states: ['Kansas', 'Massachusetts', 'New York', 'Pennsylvania'], }, { title: 'Three', states: ['Tennessee', 'Virginia', 'Wyoming', 'Alaska'], }, ]; } @Component({ template: ` <mat-form-field> <input matInput placeholder="State" [matAutocomplete]="auto" [(ngModel)]="selectedState"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @if (true) { @for (group of stateGroups; track group) { <mat-optgroup [label]="group.title"> @for (state of group.states; track state) { <mat-option [value]="state"> <span>{{ state }}</span> </mat-option> } </mat-optgroup> } } </mat-autocomplete> `, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatOptgroup, MatOption, MatInputModule, FormsModule, ], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithIndirectGroups extends AutocompleteWithGroups {} @Component({ template: ` <mat-form-field> <input matInput placeholder="State" [matAutocomplete]="auto" [(ngModel)]="selectedState"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete" (optionSelected)="optionSelected($event)"> @for (state of states; track state) { <mat-option [value]="state"> <span>{{ state }}</span> </mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithSelectEvent { selectedState!: string; states = ['New York', 'Washington', 'Oregon']; optionSelected = jasmine.createSpy('optionSelected callback'); @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; @ViewChild(MatAutocomplete) autocomplete!: MatAutocomplete; } @Component({ template: ` <input [formControl]="formControl" [matAutocomplete]="auto"/> <mat-autocomplete #auto="matAutocomplete"></mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, ReactiveFormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class PlainAutocompleteInputWithFormControl { formControl = new FormControl(''); } @Component({ template: ` <mat-form-field> <input type="number" matInput [matAutocomplete]="auto" [(ngModel)]="selectedValue"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> @for (value of values; track value) { <mat-option [value]="value">{{value}}</mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithNumberInputAndNgModel { selectedValue!: number; values = [1, 2, 3]; } @Component({ template: ` <div> <mat-form-field> <input matInput [matAutocomplete]="auto" [matAutocompleteConnectedTo]="connectedTo" [(ngModel)]="selectedValue"> </mat-form-field> </div> <div class="origin" matAutocompleteOrigin #origin="matAutocompleteOrigin" style="margin-top: 50px"> Connection element </div> <mat-autocomplete #auto="matAutocomplete"> @for (value of values; track value) { <mat-option [value]="value">{{value}}</mat-option> } </mat-autocomplete> `, imports: [ MatAutocomplete, MatAutocompleteTrigger, MatAutocompleteOrigin, MatOption, MatInputModule, FormsModule, ], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithDifferentOrigin { @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; @ViewChild(MatAutocompleteOrigin) alternateOrigin!: MatAutocompleteOrigin; selectedValue!: string; values = ['one', 'two', 'three']; connectedTo!: MatAutocompleteOrigin; } @Component({ template: ` <input autocomplete="changed" [(ngModel)]="value" [matAutocomplete]="auto"/> <mat-autocomplete #auto="matAutocomplete"></mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, FormsModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithNativeAutocompleteAttribute { value!: string; } @Component({ template: '<input [matAutocomplete]="null!" matAutocompleteDisabled>', imports: [MatAutocompleteTrigger], changeDetection: ChangeDetectionStrategy.Eager, }) class InputWithoutAutocompleteAndDisabled {} @Component({ template: ` <mat-form-field> <input matInput [matAutocomplete]="auto"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete" (optionActivated)="optionActivated($event)"> @for (state of states; track state) { <mat-option [value]="state">{{ state }}</mat-option> } </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithActivatedEvent { states = ['California', 'West Virginia', 'Florida']; optionActivated = jasmine.createSpy('optionActivated callback'); @ViewChild(MatAutocompleteTrigger) trigger!: MatAutocompleteTrigger; @ViewChild(MatAutocomplete) autocomplete!: MatAutocomplete; @ViewChildren(MatOption) options!: QueryList<MatOption>; } @Component({ template: ` <mat-form-field> <input matInput [matAutocomplete]="auto"> </mat-form-field> <mat-autocomplete #auto="matAutocomplete"> </mat-autocomplete> `, imports: [MatAutocomplete, MatAutocompleteTrigger, MatInputModule], changeDetection: ChangeDetectionStrategy.Eager, }) class AutocompleteWithoutOptions { @ViewChild(MatAutocompleteTrigger, {static: true}) trigger!: MatAutocompleteTrigger; }