sass-references/angular-material/material/chips/chip-grid.spec.ts

1273 lines
42 KiB
TypeScript

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<MatChipRow>;
let testComponent: StandardChipGrid;
let directionality: {value: Direction; change: EventEmitter<Direction>};
let primaryActions: NodeListOf<HTMLElement>;
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<StandardChipGrid>;
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<StandardChipGrid>
| ComponentFixture<StandardChipGridWithAnimations>;
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<ChipGridWithRemove>;
let trailingActions: NodeListOf<HTMLElement>;
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<StandardChipGrid>;
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<StandardChipGrid>;
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<FormFieldChipGrid>;
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<ChipGridWithRemove>;
let trailingActions: NodeListOf<HTMLElement>;
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<InputChipGrid>;
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<HTMLElement>(
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<ChipGridWithFormErrorMessages>;
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<T>(
component: Type<T>,
animationsModule:
| Type<NoopAnimationsModule>
| Type<BrowserAnimationsModule> = NoopAnimationsModule,
direction: Direction = 'ltr',
): ComponentFixture<T> {
directionality = {
value: direction,
change: new EventEmitter<Direction>(),
} as Directionality;
TestBed.configureTestingModule({
imports: [
FormsModule,
ReactiveFormsModule,
MatChipsModule,
MatFormFieldModule,
MatInputModule,
animationsModule,
],
providers: [{provide: Directionality, useValue: directionality}],
declarations: [component],
});
const fixture = TestBed.createComponent<T>(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<HTMLElement>(
'.mdc-evolution-chip__action--primary',
);
return fixture;
}
});
@Component({
template: `
<mat-chip-grid [tabIndex]="tabIndex" [role]="role" #chipGrid>
@for (i of chips; track i) {
<mat-chip-row [editable]="editable">{{name}} {{i + 1}}</mat-chip-row>
}
</mat-chip-grid>
<input name="test" [matChipInputFor]="chipGrid"/>`,
standalone: false,
})
class StandardChipGrid {
name: string = 'Test';
tabIndex: number = 0;
chips = [0, 1, 2, 3, 4];
editable = false;
role: string | null = null;
}
@Component({
template: `
<mat-form-field>
<mat-label>Add a chip</mat-label>
<mat-chip-grid #chipGrid>
@for (chip of chips; track chip) {
<mat-chip-row (removed)="remove(chip)">{{chip}}</mat-chip-row>
}
</mat-chip-grid>
<input name="test" [matChipInputFor]="chipGrid"/>
</mat-form-field>
`,
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: `
<mat-form-field>
<mat-label>New food...</mat-label>
<mat-chip-grid #chipGrid placeholder="Food" [formControl]="control">
@for (food of foods; track food) {
<mat-chip-row [value]="food.value" (removed)="remove(food)">
{{ food.viewValue }}
</mat-chip-row>
}
</mat-chip-grid>
<input
[matChipInputFor]="chipGrid"
[matChipInputSeparatorKeyCodes]="separatorKeyCodes"
[matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)"/>
</mat-form-field>
`,
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<string | null>(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<MatChipRow>;
}
@Component({
template: `
<form #form="ngForm" novalidate>
<mat-form-field>
<mat-chip-grid #chipGrid [formControl]="formControl">
@for (food of foods; track food) {
<mat-chip-row [value]="food.value">{{food.viewValue}}</mat-chip-row>
}
</mat-chip-grid>
<input name="test" [matChipInputFor]="chipGrid"/>
<mat-hint>Please select a chip, or type to add a new chip</mat-hint>
<mat-error>Should have value</mat-error>
</mat-form-field>
</form>
`,
standalone: false,
})
class ChipGridWithFormErrorMessages {
foods: any[] = [
{value: 0, viewValue: 'Steak'},
{value: 1, viewValue: 'Pizza'},
{value: 2, viewValue: 'Pasta'},
];
@ViewChildren(MatChipRow) chips: QueryList<MatChipRow>;
@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: `
<mat-chip-grid #chipGrid>
@for (i of numbers; track i) {
<mat-chip-row (removed)="remove(i)">{{i}}</mat-chip-row>
}
<input name="test" [matChipInputFor]="chipGrid"/>
</mat-chip-grid>`,
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: `
<mat-form-field>
<mat-chip-grid #chipGrid>
@for (i of chips; track i) {
<mat-chip-row [value]="i" (removed)="removeChip($event)">
Chip {{i + 1}}
<span matChipRemove>Remove</span>
</mat-chip-row>
}
</mat-chip-grid>
<input name="test" [matChipInputFor]="chipGrid"/>
</mat-form-field>
`,
standalone: false,
})
class ChipGridWithRemove {
chips = [0, 1, 2, 3, 4];
removeChip(event: MatChipEvent) {
this.chips.splice(event.chip.value, 1);
}
}