import {animate, style, transition, trigger} from '@angular/animations'; import {Direction, Directionality} from '@angular/cdk/bidi'; import { BACKSPACE, DELETE, DOWN_ARROW, END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB, UP_ARROW, } from '@angular/cdk/keycodes'; import { createKeyboardEvent, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, patchElementFocus, typeInElement, } from '@angular/cdk/testing/private'; import { ChangeDetectorRef, Component, DebugElement, EventEmitter, QueryList, Type, ViewChild, ViewChildren, inject, } from '@angular/core'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; import {By} from '@angular/platform-browser'; import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MatChipEvent, MatChipGrid, MatChipInputEvent, MatChipRow, MatChipsModule} from './index'; describe('MatChipGrid', () => { let chipGridDebugElement: DebugElement; let chipGridNativeElement: HTMLElement; let chipGridInstance: MatChipGrid; let chips: QueryList; let testComponent: StandardChipGrid; let directionality: {value: Direction; change: EventEmitter}; let primaryActions: NodeListOf; const expectNoCellFocused = () => { expect(Array.from(primaryActions)).not.toContain(document.activeElement as HTMLElement); }; const expectLastCellFocused = () => { expect(document.activeElement).toBe(primaryActions[primaryActions.length - 1]); }; describe('StandardChipGrid', () => { describe('basic behaviors', () => { let fixture: ComponentFixture; beforeEach(() => { fixture = createComponent(StandardChipGrid); }); it('should add the `mat-mdc-chip-set` class', () => { expect(chipGridNativeElement.classList).toContain('mat-mdc-chip-set'); }); it('should toggle the chips disabled state based on whether it is disabled', () => { expect(chips.toArray().every(chip => chip.disabled)).toBe(false); chipGridInstance.disabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chips.toArray().every(chip => chip.disabled)).toBe(true); chipGridInstance.disabled = false; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chips.toArray().every(chip => chip.disabled)).toBe(false); }); it('should disable a chip that is added after the list became disabled', fakeAsync(() => { expect(chips.toArray().every(chip => chip.disabled)).toBe(false); chipGridInstance.disabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chips.toArray().every(chip => chip.disabled)).toBe(true); fixture.componentInstance.chips.push(5, 6); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tick(); fixture.detectChanges(); expect(chips.toArray().every(chip => chip.disabled)).toBe(true); })); it('should not set a role on the grid when the list is empty', () => { testComponent.chips = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chipGridNativeElement.hasAttribute('role')).toBe(false); }); it('should be able to set a custom role', () => { testComponent.role = 'listbox'; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chipGridNativeElement.getAttribute('role')).toBe('listbox'); }); }); describe('focus behaviors', () => { let fixture: | ComponentFixture | ComponentFixture; beforeEach(() => { fixture = createComponent(StandardChipGrid); }); it('should focus the first chip on focus', () => { chipGridInstance.focus(); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[0]); }); it('should focus the primary action when calling the `focus` method', () => { chips.last.focus(); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[primaryActions.length - 1]); }); it('should not be able to become focused when disabled', () => { expect(chipGridInstance.focused) .withContext('Expected grid to not be focused.') .toBe(false); chipGridInstance.disabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); chipGridInstance.focus(); fixture.detectChanges(); expect(chipGridInstance.focused) .withContext('Expected grid to continue not to be focused') .toBe(false); }); it('should remove the tabindex from the grid if it is disabled', () => { expect(chipGridNativeElement.getAttribute('tabindex')).toBe('0'); chipGridInstance.disabled = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chipGridNativeElement.getAttribute('tabindex')).toBe('-1'); }); describe('on chip destroy', () => { it('should focus the next item', () => { const midItem = chips.get(2)!; // Focus the middle item midItem.focus(); // Destroy the middle item testComponent.chips.splice(2, 1); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); // It focuses the 4th item expect(document.activeElement).toBe(primaryActions[3]); }); it('should focus the previous item', () => { // Focus the last item chips.last.focus(); // Destroy the last item testComponent.chips.pop(); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); // It focuses the next-to-last item expect(document.activeElement).toBe(primaryActions[primaryActions.length - 2]); }); it('should not focus if chip grid is not focused', fakeAsync(() => { const midItem = chips.get(2)!; // Focus and blur the middle item midItem.focus(); (document.activeElement as HTMLElement).blur(); tick(); // Destroy the middle item testComponent.chips.splice(2, 1); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); // Should not have focus expect(chipGridNativeElement.contains(document.activeElement)).toBe(false); })); it('should focus the grid if the last focused item is removed', () => { testComponent.chips = [0]; fixture.changeDetectorRef.markForCheck(); spyOn(chipGridInstance, 'focus'); patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); chips.last.focus(); testComponent.chips.pop(); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chipGridInstance.focus).toHaveBeenCalled(); }); it('should move focus to the last chip when the focused chip was deleted inside a component with animations', fakeAsync(() => { fixture.destroy(); TestBed.resetTestingModule(); fixture = createComponent(StandardChipGridWithAnimations, BrowserAnimationsModule); patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); chips.last.focus(); fixture.detectChanges(); dispatchKeyboardEvent(chips.last._elementRef.nativeElement, 'keydown', BACKSPACE); fixture.detectChanges(); tick(500); expect(document.activeElement).toBe(primaryActions[primaryActions.length - 2]); })); }); it('should have a focus indicator', () => { const focusIndicators = chipGridNativeElement.querySelectorAll( '.mat-mdc-chip-primary-focus-indicator', ); expect(focusIndicators.length).toBeGreaterThan(0); expect(focusIndicators.length).toBe(chips.length); }); }); describe('keyboard behavior', () => { describe('LTR (default)', () => { let fixture: ComponentFixture; let trailingActions: NodeListOf; beforeEach(fakeAsync(() => { fixture = createComponent(ChipGridWithRemove); flush(); trailingActions = chipGridNativeElement.querySelectorAll( '.mdc-evolution-chip__action--trailing', ); })); it('should focus previous column when press LEFT ARROW', () => { const lastIndex = primaryActions.length - 1; // Focus the first column of the last chip in the array chips.last.focus(); expect(document.activeElement).toBe(primaryActions[lastIndex]); // Press the LEFT arrow dispatchKeyboardEvent(primaryActions[lastIndex], 'keydown', LEFT_ARROW); fixture.detectChanges(); // It focuses the last column of the previous chip expect(document.activeElement).toBe(trailingActions[lastIndex - 1]); }); it('should focus next column when press RIGHT ARROW', () => { // Focus the first column of the first chip in the array chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); // Press the RIGHT arrow dispatchKeyboardEvent(primaryActions[0], 'keydown', RIGHT_ARROW); fixture.detectChanges(); // It focuses the next column of the chip expect(document.activeElement).toBe(trailingActions[0]); }); it('should not handle arrow key events from non-chip elements', () => { const previousActiveElement = document.activeElement; dispatchKeyboardEvent(chipGridNativeElement, 'keydown', RIGHT_ARROW); fixture.detectChanges(); expect(document.activeElement) .withContext('Expected focused item not to have changed.') .toBe(previousActiveElement); }); it('should focus primary action in next row when pressing DOWN ARROW on primary action', () => { chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); dispatchKeyboardEvent(primaryActions[0], 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[1]); }); it('should focus primary action in previous row when pressing UP ARROW on primary action', () => { const lastIndex = primaryActions.length - 1; chips.last.focus(); expect(document.activeElement).toBe(primaryActions[lastIndex]); dispatchKeyboardEvent(primaryActions[lastIndex], 'keydown', UP_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[lastIndex - 1]); }); it('should focus(trailing action in next row when pressing DOWN ARROW on(trailing action', () => { trailingActions[0].focus(); expect(document.activeElement).toBe(trailingActions[0]); dispatchKeyboardEvent(trailingActions[0], 'keydown', DOWN_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(trailingActions[1]); }); it('should focus trailing action in previous row when pressing UP ARROW on trailing action', () => { const lastIndex = trailingActions.length - 1; trailingActions[lastIndex].focus(); expect(document.activeElement).toBe(trailingActions[lastIndex]); dispatchKeyboardEvent(trailingActions[lastIndex], 'keydown', UP_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(trailingActions[lastIndex - 1]); }); }); describe('RTL', () => { let fixture: ComponentFixture; beforeEach(() => { fixture = createComponent(StandardChipGrid, undefined, 'rtl'); }); it('should focus previous column when press RIGHT ARROW', () => { const lastIndex = primaryActions.length - 1; // Focus the first column of the last chip in the array chips.last.focus(); expect(document.activeElement).toBe(primaryActions[lastIndex]); // Press the RIGHT arrow dispatchKeyboardEvent(primaryActions[lastIndex], 'keydown', RIGHT_ARROW); fixture.detectChanges(); // It focuses the last column of the previous chip expect(document.activeElement).toBe(primaryActions[lastIndex - 1]); }); it('should focus next column when press LEFT ARROW', () => { // Focus the first column of the first chip in the array chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); // Press the LEFT arrow dispatchKeyboardEvent(primaryActions[0], 'keydown', LEFT_ARROW); fixture.detectChanges(); // It focuses the next column of the chip expect(document.activeElement).toBe(primaryActions[1]); }); it('should allow focus to escape when tabbing away', fakeAsync(() => { let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); let firstNativeChip = nativeChips[0] as HTMLElement; dispatchKeyboardEvent(firstNativeChip, 'keydown', TAB); expect(chipGridNativeElement.tabIndex) .withContext('Expected tabIndex to be set to -1 temporarily.') .toBe(-1); flush(); expect(chipGridNativeElement.tabIndex) .withContext('Expected tabIndex to be reset back to 0') .toBe(0); })); it('should use user defined tabIndex', fakeAsync(() => { chipGridInstance.tabIndex = 4; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(chipGridNativeElement.tabIndex) .withContext('Expected tabIndex to be set to user defined value 4.') .toBe(4); let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); let firstNativeChip = nativeChips[0] as HTMLElement; dispatchKeyboardEvent(firstNativeChip, 'keydown', TAB); expect(chipGridNativeElement.tabIndex) .withContext('Expected tabIndex to be set to -1 temporarily.') .toBe(-1); flush(); expect(chipGridNativeElement.tabIndex) .withContext('Expected tabIndex to be reset back to 4') .toBe(4); })); }); describe('keydown behavior', () => { let fixture: ComponentFixture; beforeEach(() => { fixture = createComponent(StandardChipGrid); }); it('should account for the direction changing', () => { chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); dispatchKeyboardEvent(primaryActions[0], 'keydown', RIGHT_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[1]); directionality.value = 'rtl'; directionality.change.next('rtl'); fixture.detectChanges(); dispatchKeyboardEvent(primaryActions[1], 'keydown', RIGHT_ARROW); fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[0]); }); it('should move focus to the first chip when pressing HOME', () => { chips.last.focus(); expect(document.activeElement).toBe(primaryActions[4]); const event = dispatchKeyboardEvent(primaryActions[4], 'keydown', HOME); fixture.detectChanges(); expect(event.defaultPrevented).toBe(true); expect(document.activeElement).toBe(primaryActions[0]); }); it('should move focus to the last chip when pressing END', () => { chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); const event = dispatchKeyboardEvent(primaryActions[0], 'keydown', END); fixture.detectChanges(); expect(event.defaultPrevented).toBe(true); expect(document.activeElement).toBe(primaryActions[4]); }); it('should ignore all non-tab navigation keyboard events from an editing chip', fakeAsync(() => { testComponent.editable = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); chips.first.focus(); expect(document.activeElement).toBe(primaryActions[0]); dispatchKeyboardEvent(document.activeElement!, 'keydown', ENTER); fixture.detectChanges(); flush(); const previousActiveElement = document.activeElement; const keysToIgnore = [HOME, END, LEFT_ARROW, RIGHT_ARROW]; for (const key of keysToIgnore) { dispatchKeyboardEvent(document.activeElement!, 'keydown', key); fixture.detectChanges(); flush(); expect(document.activeElement).toBe(previousActiveElement); } })); }); }); }); describe('FormFieldChipGrid', () => { let fixture: ComponentFixture; beforeEach(() => { fixture = createComponent(FormFieldChipGrid); }); describe('keyboard behavior', () => { it('should maintain focus if the active chip is deleted', () => { const secondChip = fixture.nativeElement.querySelectorAll('.mat-mdc-chip')[1]; const secondChipAction = secondChip.querySelector('.mdc-evolution-chip__action--primary'); secondChipAction.focus(); fixture.detectChanges(); expect(chipGridInstance._chips.toArray().findIndex(chip => chip._hasFocus())).toBe(1); dispatchKeyboardEvent(secondChip, 'keydown', DELETE); fixture.detectChanges(); expect(chipGridInstance._chips.toArray().findIndex(chip => chip._hasFocus())).toBe(1); }); describe('when the input has focus', () => { it('should not focus the last chip when press DELETE', () => { let nativeInput = fixture.nativeElement.querySelector('input'); // Focus the input nativeInput.focus(); expectNoCellFocused(); // Press the DELETE key dispatchKeyboardEvent(nativeInput, 'keydown', DELETE); fixture.detectChanges(); // It doesn't focus the last chip expectNoCellFocused(); }); it('should focus the last chip when press BACKSPACE', () => { let nativeInput = fixture.nativeElement.querySelector('input'); // Focus the input nativeInput.focus(); expectNoCellFocused(); // Press the BACKSPACE key dispatchKeyboardEvent(nativeInput, 'keydown', BACKSPACE); fixture.detectChanges(); // It focuses the last chip expectLastCellFocused(); }); it('should not focus the last chip when pressing BACKSPACE on a non-empty input', () => { const nativeInput = fixture.nativeElement.querySelector('input'); nativeInput.value = 'hello'; nativeInput.focus(); fixture.detectChanges(); expectNoCellFocused(); dispatchKeyboardEvent(nativeInput, 'keydown', BACKSPACE); fixture.detectChanges(); expectNoCellFocused(); }); }); }); it('should complete the stateChanges stream on destroy', () => { const spy = jasmine.createSpy('stateChanges complete'); const subscription = chipGridInstance.stateChanges.subscribe({complete: spy}); fixture.destroy(); expect(spy).toHaveBeenCalled(); subscription.unsubscribe(); }); }); describe('with chip remove', () => { let fixture: ComponentFixture; let trailingActions: NodeListOf; beforeEach(fakeAsync(() => { fixture = createComponent(ChipGridWithRemove); flush(); trailingActions = chipGridNativeElement.querySelectorAll( '.mdc-evolution-chip__action--trailing', ); })); it('should properly focus next item if chip is removed through click', fakeAsync(() => { const chip = chips.get(2)!; chip.focus(); fixture.detectChanges(); // Destroy the third focused chip by dispatching a bubbling click event on the // associated chip remove element. trailingActions[2].click(); fixture.detectChanges(); flush(); expect(document.activeElement).toBe(primaryActions[3]); })); }); describe('chip grid with chip input', () => { let fixture: ComponentFixture; let nativeChips: HTMLElement[]; let nativeInput: HTMLInputElement; let nativeChipGrid: HTMLElement; beforeEach(() => { fixture = createComponent(InputChipGrid); nativeChips = fixture.debugElement .queryAll(By.css('mat-chip-row')) .map(chip => chip.nativeElement); nativeChipGrid = fixture.debugElement.query(By.css('mat-chip-grid'))!.nativeElement; nativeInput = fixture.nativeElement.querySelector('input'); }); it('should take an initial view value with reactive forms', () => { fixture.componentInstance.control = new FormControl('[pizza-1]'); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); expect(fixture.componentInstance.chipGrid.value).toEqual('[pizza-1]'); }); it('should set the view value from the form', () => { const chipGrid = fixture.componentInstance.chipGrid; expect(chipGrid.value).withContext('Expect chip grid to have no initial value').toBeFalsy(); fixture.componentInstance.control.setValue('[pizza-1]'); fixture.detectChanges(); expect(chipGrid.value).toEqual('[pizza-1]'); }); it('should update the form value when the view changes', fakeAsync(() => { expect(fixture.componentInstance.control.value) .withContext(`Expected the control's value to be empty initially.`) .toEqual(null); nativeInput.focus(); typeInElement(nativeInput, '123'); fixture.detectChanges(); dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); fixture.detectChanges(); flush(); dispatchFakeEvent(nativeInput, 'blur'); flush(); expect(fixture.componentInstance.control.value).toContain('123-8'); })); it('should clear the value when the control is reset', () => { fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); fixture.componentInstance.control.reset(); fixture.detectChanges(); expect(fixture.componentInstance.chipGrid.value).toEqual(null); }); it('should set the control to touched when the chip grid is touched', fakeAsync(() => { expect(fixture.componentInstance.control.touched) .withContext('Expected the control to start off as untouched.') .toBe(false); dispatchFakeEvent(nativeChipGrid, 'blur'); tick(); expect(fixture.componentInstance.control.touched) .withContext('Expected the control to be touched.') .toBe(true); })); it('should not set touched when a disabled chip grid is touched', fakeAsync(() => { expect(fixture.componentInstance.control.touched) .withContext('Expected the control to start off as untouched.') .toBe(false); fixture.componentInstance.control.disable(); dispatchFakeEvent(nativeChipGrid, 'blur'); tick(); expect(fixture.componentInstance.control.touched) .withContext('Expected the control to stay untouched.') .toBe(false); })); it("should set the control to dirty when the chip grid's value changes in the DOM", fakeAsync(() => { expect(fixture.componentInstance.control.dirty) .withContext(`Expected control to start out pristine.`) .toEqual(false); nativeInput.focus(); typeInElement(nativeInput, '123'); fixture.detectChanges(); dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); fixture.detectChanges(); flush(); dispatchFakeEvent(nativeInput, 'blur'); flush(); expect(fixture.componentInstance.control.dirty) .withContext(`Expected control to be dirty after value was changed by user.`) .toEqual(true); })); it('should not set the control to dirty when the value changes programmatically', () => { expect(fixture.componentInstance.control.dirty) .withContext(`Expected control to start out pristine.`) .toEqual(false); fixture.componentInstance.control.setValue('pizza-1'); expect(fixture.componentInstance.control.dirty) .withContext(`Expected control to stay pristine after programmatic change.`) .toEqual(false); }); it('should set an asterisk after the placeholder if the control is required', () => { let requiredMarker = fixture.debugElement.query( By.css('.mat-mdc-form-field-required-marker'), )!; expect(requiredMarker) .withContext(`Expected placeholder not to have an asterisk, as control was not required.`) .toBeNull(); fixture.componentInstance.chipGrid.required = true; fixture.detectChanges(); requiredMarker = fixture.debugElement.query(By.css('.mat-mdc-form-field-required-marker'))!; expect(requiredMarker) .not.withContext(`Expected placeholder to have an asterisk, as control was required.`) .toBeNull(); }); it('should mark the component as required if the control has a required validator', () => { fixture.destroy(); fixture = TestBed.createComponent(InputChipGrid); fixture.componentInstance.control = new FormControl('', [Validators.required]); fixture.detectChanges(); expect( fixture.nativeElement.querySelector('.mat-mdc-form-field-required-marker'), ).toBeTruthy(); }); it('should blur the form field when the active chip is blurred', fakeAsync(() => { const formField: HTMLElement = fixture.nativeElement.querySelector('.mat-mdc-form-field'); const firstAction = nativeChips[0].querySelector('.mat-mdc-chip-action') as HTMLElement; patchElementFocus(firstAction); firstAction.focus(); fixture.detectChanges(); expect(formField.classList).toContain('mat-focused'); firstAction.blur(); fixture.detectChanges(); fixture.detectChanges(); fixture.detectChanges(); flush(); expect(formField.classList).not.toContain('mat-focused'); })); it('should keep focus on the input after adding the first chip', fakeAsync(() => { const chipEls = Array.from( fixture.nativeElement.querySelectorAll('mat-chip-row'), ).reverse(); // Remove the chips via backspace to simulate the user removing them. chipEls.forEach(chip => { chip.focus(); dispatchKeyboardEvent(chip, 'keydown', BACKSPACE); fixture.detectChanges(); tick(); }); nativeInput.focus(); expect(fixture.componentInstance.foods) .withContext('Expected all chips to be removed.') .toEqual([]); expect(document.activeElement).withContext('Expected input to be focused.').toBe(nativeInput); typeInElement(nativeInput, '123'); fixture.detectChanges(); dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); fixture.detectChanges(); tick(); expect(document.activeElement) .withContext('Expected input to remain focused.') .toBe(nativeInput); })); it('should set aria-invalid if the form field is invalid', fakeAsync(() => { fixture.componentInstance.control = new FormControl('', [Validators.required]); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); expect(input.getAttribute('aria-invalid')).toBe('true'); typeInElement(input, '123'); fixture.detectChanges(); dispatchKeyboardEvent(input, 'keydown', ENTER); fixture.detectChanges(); flush(); dispatchFakeEvent(input, 'blur'); flush(); fixture.detectChanges(); expect(input.getAttribute('aria-invalid')).toBe('false'); })); describe('when the input has focus', () => { beforeEach(() => { nativeInput.focus(); expectNoCellFocused(); }); it('should not focus the last chip when pressing DELETE', () => { dispatchKeyboardEvent(nativeInput, 'keydown', DELETE); expectNoCellFocused(); }); it('should focus the last chip when pressing BACKSPACE when input is empty', () => { dispatchKeyboardEvent(nativeInput, 'keydown', BACKSPACE); expectLastCellFocused(); }); it('should not focus the last chip when the BACKSPACE key is being repeated', () => { // Only now should it focus the last element const event = createKeyboardEvent('keydown', BACKSPACE); Object.defineProperty(event, 'repeat', { get: () => true, }); dispatchEvent(nativeInput, event); expectNoCellFocused(); }); it('should focus last chip after pressing BACKSPACE after creating a chip', () => { // Create a chip typeInElement(nativeInput, '123'); dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); expectNoCellFocused(); dispatchKeyboardEvent(nativeInput, 'keydown', BACKSPACE); expectLastCellFocused(); }); }); }); describe('error messages', () => { let fixture: ComponentFixture; let errorTestComponent: ChipGridWithFormErrorMessages; let containerEl: HTMLElement; let chipGridEl: HTMLElement; let inputEl: HTMLElement; beforeEach(fakeAsync(() => { fixture = createComponent(ChipGridWithFormErrorMessages); flush(); fixture.detectChanges(); errorTestComponent = fixture.componentInstance; containerEl = fixture.debugElement.query(By.css('mat-form-field'))!.nativeElement; chipGridEl = fixture.debugElement.query(By.css('mat-chip-grid'))!.nativeElement; inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; })); it('should not show any errors if the user has not interacted', () => { expect(errorTestComponent.formControl.untouched) .withContext('Expected untouched form control') .toBe(true); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected no error message') .toBe(0); expect(chipGridEl.getAttribute('aria-invalid')) .withContext('Expected aria-invalid to be set to "false".') .toBe('false'); }); it('should display an error message when the grid is touched and invalid', fakeAsync(() => { expect(errorTestComponent.formControl.invalid) .withContext('Expected form control to be invalid') .toBe(true); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected no error message') .toBe(0); errorTestComponent.formControl.markAsTouched(); fixture.detectChanges(); tick(); expect(containerEl.classList) .withContext('Expected container to have the invalid CSS class.') .toContain('mat-form-field-invalid'); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected one error message to have been rendered.') .toBe(1); expect(chipGridEl.getAttribute('aria-invalid')) .withContext('Expected aria-invalid to be set to "true".') .toBe('true'); })); it('should display an error message when the parent form is submitted', fakeAsync(() => { expect(errorTestComponent.form.submitted) .withContext('Expected form not to have been submitted') .toBe(false); expect(errorTestComponent.formControl.invalid) .withContext('Expected form control to be invalid') .toBe(true); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected no error message') .toBe(0); dispatchFakeEvent(fixture.debugElement.query(By.css('form'))!.nativeElement, 'submit'); flush(); fixture.detectChanges(); fixture.whenStable().then(() => { expect(errorTestComponent.form.submitted) .withContext('Expected form to have been submitted') .toBe(true); expect(containerEl.classList) .withContext('Expected container to have the invalid CSS class.') .toContain('mat-form-field-invalid'); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected one error message to have been rendered.') .toBe(1); expect(chipGridEl.getAttribute('aria-invalid')) .withContext('Expected aria-invalid to be set to "true".') .toBe('true'); }); flush(); })); it('should hide the errors and show the hints once the chip grid becomes valid', fakeAsync(() => { errorTestComponent.formControl.markAsTouched(); flush(); fixture.detectChanges(); fixture.whenStable().then(() => { expect(containerEl.classList) .withContext('Expected container to have the invalid CSS class.') .toContain('mat-form-field-invalid'); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected one error message to have been rendered.') .toBe(1); expect(containerEl.querySelectorAll('mat-hint').length) .withContext('Expected no hints to be shown.') .toBe(0); errorTestComponent.formControl.setValue('something'); flush(); fixture.detectChanges(); fixture.whenStable().then(() => { expect(containerEl.classList).not.toContain( 'mat-form-field-invalid', 'Expected container not to have the invalid class when valid.', ); expect(containerEl.querySelectorAll('mat-error').length) .withContext('Expected no error messages when the input is valid.') .toBe(0); expect(containerEl.querySelectorAll('mat-hint').length) .withContext('Expected one hint to be shown once the input is valid.') .toBe(1); }); flush(); }); })); it('should set the proper aria-live attribute on the error messages', () => { errorTestComponent.formControl.markAsTouched(); fixture.detectChanges(); expect(containerEl.querySelector('mat-error')!.getAttribute('aria-live')).toBe('polite'); }); it('sets the aria-describedby on the input to reference errors when in error state', fakeAsync(() => { let hintId = fixture.debugElement .query(By.css('.mat-mdc-form-field-hint'))! .nativeElement.getAttribute('id'); let describedBy = inputEl.getAttribute('aria-describedby'); expect(hintId).withContext('hint should be shown').toBeTruthy(); expect(describedBy).toBe(hintId); fixture.componentInstance.formControl.markAsTouched(); fixture.detectChanges(); // Flush the describedby timer and detect changes caused by it. flush(); fixture.detectChanges(); let errorIds = fixture.debugElement .queryAll(By.css('.mat-mdc-form-field-error')) .map(el => el.nativeElement.getAttribute('id')) .join(' '); let errorDescribedBy = inputEl.getAttribute('aria-describedby'); expect(errorIds).withContext('errors should be shown').toBeTruthy(); expect(errorDescribedBy).toBe(errorIds); })); }); function createComponent( component: Type, animationsModule: | Type | Type = NoopAnimationsModule, direction: Direction = 'ltr', ): ComponentFixture { directionality = { value: direction, change: new EventEmitter(), } as Directionality; TestBed.configureTestingModule({ imports: [ FormsModule, ReactiveFormsModule, MatChipsModule, MatFormFieldModule, MatInputModule, animationsModule, ], providers: [{provide: Directionality, useValue: directionality}], declarations: [component], }); const fixture = TestBed.createComponent(component); fixture.detectChanges(); chipGridDebugElement = fixture.debugElement.query(By.directive(MatChipGrid))!; chipGridNativeElement = chipGridDebugElement.nativeElement; chipGridInstance = chipGridDebugElement.componentInstance; testComponent = fixture.debugElement.componentInstance; chips = chipGridInstance._chips; primaryActions = chipGridNativeElement.querySelectorAll( '.mdc-evolution-chip__action--primary', ); return fixture; } }); @Component({ template: ` @for (i of chips; track i) { {{name}} {{i + 1}} } `, standalone: false, }) class StandardChipGrid { name: string = 'Test'; tabIndex: number = 0; chips = [0, 1, 2, 3, 4]; editable = false; role: string | null = null; } @Component({ template: ` Add a chip @for (chip of chips; track chip) { {{chip}} } `, standalone: false, }) class FormFieldChipGrid { chips = ['Chip 0', 'Chip 1', 'Chip 2']; remove(chip: string) { const index = this.chips.indexOf(chip); if (index > -1) { this.chips.splice(index, 1); } } } @Component({ template: ` New food... @for (food of foods; track food) { {{ food.viewValue }} } `, standalone: false, }) class InputChipGrid { foods: any[] = [ {value: 'steak-0', viewValue: 'Steak'}, {value: 'pizza-1', viewValue: 'Pizza'}, {value: 'tacos-2', viewValue: 'Tacos', disabled: true}, {value: 'sandwich-3', viewValue: 'Sandwich'}, {value: 'chips-4', viewValue: 'Chips'}, {value: 'eggs-5', viewValue: 'Eggs'}, {value: 'pasta-6', viewValue: 'Pasta'}, {value: 'sushi-7', viewValue: 'Sushi'}, ]; control = new FormControl(null); separatorKeyCodes = [ENTER, SPACE]; addOnBlur: boolean = true; add(event: MatChipInputEvent): void { const value = (event.value || '').trim(); // Add our foods if (value) { this.foods.push({ value: `${value.toLowerCase()}-${this.foods.length}`, viewValue: value, }); } // Reset the input value event.chipInput!.clear(); } remove(food: any): void { const index = this.foods.indexOf(food); if (index > -1) { this.foods.splice(index, 1); } } @ViewChild(MatChipGrid) chipGrid: MatChipGrid; @ViewChildren(MatChipRow) chips: QueryList; } @Component({ template: `
@for (food of foods; track food) { {{food.viewValue}} } Please select a chip, or type to add a new chip Should have value
`, standalone: false, }) class ChipGridWithFormErrorMessages { foods: any[] = [ {value: 0, viewValue: 'Steak'}, {value: 1, viewValue: 'Pizza'}, {value: 2, viewValue: 'Pasta'}, ]; @ViewChildren(MatChipRow) chips: QueryList; @ViewChild('form') form: NgForm; formControl = new FormControl('', Validators.required); private readonly _changeDetectorRef = inject(ChangeDetectorRef); constructor() { this.formControl.events.pipe(takeUntilDestroyed()).subscribe(() => { this._changeDetectorRef.markForCheck(); }); } } @Component({ template: ` @for (i of numbers; track i) { {{i}} } `, animations: [ // For the case we're testing this animation doesn't // have to be used anywhere, it just has to be defined. trigger('dummyAnimation', [ transition(':leave', [style({opacity: 0}), animate('500ms', style({opacity: 1}))]), ]), ], standalone: false, }) class StandardChipGridWithAnimations { numbers = [0, 1, 2, 3, 4]; remove(item: number): void { const index = this.numbers.indexOf(item); if (index > -1) { this.numbers.splice(index, 1); } } } @Component({ template: ` @for (i of chips; track i) { Chip {{i + 1}} Remove } `, standalone: false, }) class ChipGridWithRemove { chips = [0, 1, 2, 3, 4]; removeChip(event: MatChipEvent) { this.chips.splice(event.chip.value, 1); } }