diff --git a/src/material-experimental/mdc-chips/BUILD.bazel b/src/material-experimental/mdc-chips/BUILD.bazel index 816feb9f42d1..19f2fdea23c7 100644 --- a/src/material-experimental/mdc-chips/BUILD.bazel +++ b/src/material-experimental/mdc-chips/BUILD.bazel @@ -1,7 +1,7 @@ package(default_visibility = ["//visibility:public"]) load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") -load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "protractor_web_test_suite") +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") ng_module( name = "mdc-chips", @@ -14,6 +14,7 @@ ng_module( deps = [ "//src/material/core", "//src/material/form-field", + "@npm//@angular/animations", "@npm//@angular/common", "@npm//@angular/core", "@npm//@angular/forms", @@ -44,26 +45,36 @@ sass_binary( ], ) -ng_e2e_test_library( - name = "e2e_test_sources", - srcs = glob(["**/*.e2e.spec.ts"]), +ng_test_library( + name = "chips_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), deps = [ - "//src/cdk/testing/e2e", + ":mdc-chips", + "//src/cdk/a11y", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/platform", + "//src/cdk/testing", + "//src/material/core", + "//src/material/form-field", + "//src/material/input", + "@npm//@angular/animations", + "@npm//@angular/common", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + "@npm//material-components-web", + "@npm//rxjs", ], ) -protractor_web_test_suite( - name = "e2e_tests", - configuration = "//src/e2e-app:protractor.conf.js", - data = [ - "//tools/axe-protractor", - "@npm//@angular/bazel", - ], - on_prepare = "//src/e2e-app:start-devserver.js", - server = "//src/e2e-app:devserver", - tags = ["e2e"], +ng_web_test_suite( + name = "unit_tests", + static_files = ["@npm//node_modules/@material/chips:dist/mdc.chips.js"], deps = [ - ":e2e_test_sources", - "//src/cdk/testing/e2e", + ":chips_tests_lib", + "//src/material-experimental:mdc_require_config.js", ], ) diff --git a/src/material-experimental/mdc-chips/chip-grid.spec.ts b/src/material-experimental/mdc-chips/chip-grid.spec.ts new file mode 100644 index 000000000000..2e294ec820aa --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-grid.spec.ts @@ -0,0 +1,1098 @@ +import {animate, style, transition, trigger} from '@angular/animations'; +import {Directionality, Direction} from '@angular/cdk/bidi'; +import { + BACKSPACE, + DELETE, + ENTER, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB +} from '@angular/cdk/keycodes'; +import { + createFakeEvent, + createKeyboardEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, + MockNgZone, + typeInElement, +} from '@angular/cdk/testing'; +import { + Component, + DebugElement, + NgZone, + Provider, + QueryList, + Type, + ViewChild, + ViewChildren, +} from '@angular/core'; +import {fakeAsync, ComponentFixture, TestBed, 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 {Subject} from 'rxjs'; +import {GridFocusKeyManager} from './grid-focus-key-manager'; +import { + MatChipEvent, + MatChipGrid, + MatChipInputEvent, + MatChipRemove, + MatChipRow, + MatChipsModule +} from './index'; + + +describe('MatChipGrid', () => { + let fixture: ComponentFixture; + let chipGridDebugElement: DebugElement; + let chipGridNativeElement: HTMLElement; + let chipGridInstance: MatChipGrid; + let chips: QueryList; + let manager: GridFocusKeyManager; + let zone: MockNgZone; + let testComponent: StandardChipGrid; + let dirChange: Subject; + + describe('StandardChipGrid', () => { + describe('basic behaviors', () => { + beforeEach(() => { + setupStandardGrid(); + }); + + 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.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + chipGridInstance.disabled = false; + 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.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + fixture.componentInstance.chips.push(5, 6); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + })); + }); + + describe('focus behaviors', () => { + beforeEach(() => { + setupStandardGrid(); + manager = chipGridInstance._keyManager; + }); + + it('should focus the first chip on focus', () => { + chipGridInstance.focus(); + fixture.detectChanges(); + + expect(manager.activeRowIndex).toBe(0); + expect(manager.activeColumnIndex).toBe(0); + }); + + it('should watch for chip focus', () => { + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + lastItem.focus(); + fixture.detectChanges(); + + expect(manager.activeRowIndex).toBe(lastIndex); + }); + + it('should not be able to become focused when disabled', () => { + expect(chipGridInstance.focused).toBe(false, 'Expected grid to not be focused.'); + + chipGridInstance.disabled = true; + fixture.detectChanges(); + + chipGridInstance.focus(); + fixture.detectChanges(); + + expect(chipGridInstance.focused).toBe(false, 'Expected grid to continue not to be focused'); + }); + + it('should remove the tabindex from the grid if it is disabled', () => { + expect(chipGridNativeElement.getAttribute('tabindex')).toBe('0'); + + chipGridInstance.disabled = true; + fixture.detectChanges(); + + expect(chipGridNativeElement.getAttribute('tabindex')).toBe('-1'); + }); + + describe('on chip destroy', () => { + it('should focus the next item', () => { + let array = chips.toArray(); + let midItem = array[2]; + + // Focus the middle item + midItem.focus(); + + // Destroy the middle item + testComponent.chips.splice(2, 1); + fixture.detectChanges(); + + // It focuses the 4th item (now at index 2) + expect(manager.activeRowIndex).toEqual(2); + }); + + it('should focus the previous item', () => { + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + // Focus the last item + lastItem.focus(); + + // Destroy the last item + testComponent.chips.pop(); + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeRowIndex).toEqual(lastIndex - 1); + }); + + it('should not focus if chip grid is not focused', fakeAsync(() => { + let array = chips.toArray(); + let midItem = array[2]; + + // Focus and blur the middle item + midItem.focus(); + midItem._focusout(); + tick(); + zone.simulateZoneExit(); + + // Destroy the middle item + testComponent.chips.splice(2, 1); + fixture.detectChanges(); + + // Should not have focus + expect(chipGridInstance._keyManager.activeRowIndex).toEqual(-1); + })); + + it('should focus the grid if the last focused item is removed', () => { + testComponent.chips = [0]; + + spyOn(chipGridInstance, 'focus'); + chips.last.focus(); + + testComponent.chips.pop(); + 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); + fixture.detectChanges(); + + chipGridDebugElement = fixture.debugElement.query(By.directive(MatChipGrid)); + chipGridNativeElement = chipGridDebugElement.nativeElement; + chipGridInstance = chipGridDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + chips = chipGridInstance._chips; + + chips.last.focus(); + fixture.detectChanges(); + + expect(chipGridInstance._keyManager.activeRowIndex).toBe(chips.length - 1); + + dispatchKeyboardEvent(chips.last._elementRef.nativeElement, 'keydown', BACKSPACE); + fixture.detectChanges(); + tick(500); + + expect(chipGridInstance._keyManager.activeRowIndex).toBe(chips.length - 1); + expect(chipGridInstance._keyManager.activeColumnIndex).toBe(0); + })); + }); + }); + + describe('keyboard behavior', () => { + describe('LTR (default)', () => { + beforeEach(() => { + fixture = createComponent(ChipGridWithRemove); + fixture.detectChanges(); + + chipGridDebugElement = fixture.debugElement.query(By.directive(MatChipGrid)); + chipGridInstance = chipGridDebugElement.componentInstance; + chipGridNativeElement = chipGridDebugElement.nativeElement; + chips = chipGridInstance._chips; + manager = chipGridInstance._keyManager; + }); + + it('should focus previous column when press LEFT ARROW', () => { + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + + let LEFT_EVENT = createKeyboardEvent('keydown', LEFT_ARROW, lastNativeChip); + let array = chips.toArray(); + let lastRowIndex = array.length - 1; + let lastChip = array[lastRowIndex]; + + // Focus the first column of the last chip in the array + lastChip.focus(); + expect(manager.activeRowIndex).toEqual(lastRowIndex); + expect(manager.activeColumnIndex).toEqual(0); + + // Press the LEFT arrow + chipGridInstance._keydown(LEFT_EVENT); + chipGridInstance._blur(); // Simulate focus leaving the list and going to the chip. + fixture.detectChanges(); + + // It focuses the last column of the previous chip + expect(manager.activeRowIndex).toEqual(lastRowIndex - 1); + expect(manager.activeColumnIndex).toEqual(1); + }); + + it('should focus next column when press RIGHT ARROW', () => { + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + // Focus the first column of the first chip in the array + firstItem.focus(); + expect(manager.activeRowIndex).toEqual(0); + expect(manager.activeColumnIndex).toEqual(0); + + // Press the RIGHT arrow + chipGridInstance._keydown(RIGHT_EVENT); + chipGridInstance._blur(); // Simulate focus leaving the list and going to the chip. + fixture.detectChanges(); + + // It focuses the next column of the chip + expect(manager.activeRowIndex).toEqual(0); + expect(manager.activeColumnIndex).toEqual(1); + }); + + it('should not handle arrow key events from non-chip elements', () => { + const event: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, chipGridNativeElement); + const initialActiveIndex = manager.activeRowIndex; + + chipGridInstance._keydown(event); + fixture.detectChanges(); + + expect(manager.activeRowIndex) + .toBe(initialActiveIndex, 'Expected focused item not to have changed.'); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupStandardGrid('rtl'); + manager = chipGridInstance._keyManager; + }); + + it('should focus previous column when press RIGHT ARROW', () => { + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, lastNativeChip); + let array = chips.toArray(); + let lastRowIndex = array.length - 1; + let lastItem = array[lastRowIndex]; + + // Focus the first column of the last chip in the array + lastItem.focus(); + expect(manager.activeRowIndex).toEqual(lastRowIndex); + expect(manager.activeColumnIndex).toEqual(0); + + + // Press the RIGHT arrow + chipGridInstance._keydown(RIGHT_EVENT); + chipGridInstance._blur(); // Simulate focus leaving the list and going to the chip. + fixture.detectChanges(); + + // It focuses the last column of the previous chip + expect(manager.activeRowIndex).toEqual(lastRowIndex - 1); + expect(manager.activeColumnIndex).toEqual(0); + }); + + it('should focus next column when press LEFT ARROW', () => { + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let LEFT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', LEFT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + // Focus the first column of the first chip in the array + firstItem.focus(); + expect(manager.activeRowIndex).toEqual(0); + expect(manager.activeColumnIndex).toEqual(0); + + + // Press the LEFT arrow + chipGridInstance._keydown(LEFT_EVENT); + chipGridInstance._blur(); // Simulate focus leaving the list and going to the chip. + fixture.detectChanges(); + + // It focuses the next column of the chip + expect(manager.activeRowIndex).toEqual(1); + expect(manager.activeColumnIndex).toEqual(0); + }); + + it('should allow focus to escape when tabbing away', fakeAsync(() => { + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + chipGridInstance._keydown(createKeyboardEvent('keydown', TAB, firstNativeChip)); + + expect(chipGridInstance.tabIndex) + .toBe(-1, 'Expected tabIndex to be set to -1 temporarily.'); + + tick(); + + expect(chipGridInstance.tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0'); + })); + + it(`should use user defined tabIndex`, fakeAsync(() => { + chipGridInstance.tabIndex = 4; + + fixture.detectChanges(); + + expect(chipGridInstance.tabIndex) + .toBe(4, 'Expected tabIndex to be set to user defined value 4.'); + + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + chipGridInstance._keydown(createKeyboardEvent('keydown', TAB, firstNativeChip)); + + expect(chipGridInstance.tabIndex) + .toBe(-1, 'Expected tabIndex to be set to -1 temporarily.'); + + tick(); + + expect(chipGridInstance.tabIndex).toBe(4, 'Expected tabIndex to be reset back to 4'); + })); + }); + + it('should account for the direction changing', () => { + setupStandardGrid(); + manager = chipGridInstance._keyManager; + + let nativeChips = chipGridNativeElement.querySelectorAll('mat-chip-row'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + firstItem.focus(); + expect(manager.activeRowIndex).toBe(0); + expect(manager.activeColumnIndex).toBe(0); + + chipGridInstance._keydown(RIGHT_EVENT); + chipGridInstance._blur(); + fixture.detectChanges(); + + expect(manager.activeRowIndex).toBe(1); + expect(manager.activeColumnIndex).toBe(0); + + dirChange.next('rtl'); + fixture.detectChanges(); + + chipGridInstance._keydown(RIGHT_EVENT); + chipGridInstance._blur(); + fixture.detectChanges(); + + expect(manager.activeRowIndex).toBe(0); + expect(manager.activeColumnIndex).toBe(0); + }); + }); + }); + + describe('FormFieldChipGrid', () => { + beforeEach(() => { + setupInputGrid(); + }); + + describe('keyboard behavior', () => { + beforeEach(() => { + manager = chipGridInstance._keyManager; + }); + + it('should maintain focus if the active chip is deleted', () => { + const secondChip = fixture.nativeElement.querySelectorAll('.mat-mdc-chip')[1]; + + secondChip.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'); + let DELETE_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', DELETE, nativeInput); + + // Focus the input + nativeInput.focus(); + expect(manager.activeRowIndex).toBe(-1); + expect(manager.activeColumnIndex).toBe(-1); + + // Press the DELETE key + chipGridInstance._keydown(DELETE_EVENT); + fixture.detectChanges(); + + // It doesn't focus the last chip + expect(manager.activeRowIndex).toEqual(-1); + expect(manager.activeColumnIndex).toBe(-1); + }); + + it('should focus the last chip when press BACKSPACE', () => { + let nativeInput = fixture.nativeElement.querySelector('input'); + let BACKSPACE_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', BACKSPACE, nativeInput); + + // Focus the input + nativeInput.focus(); + expect(manager.activeRowIndex).toBe(-1); + expect(manager.activeColumnIndex).toBe(-1); + + // Press the BACKSPACE key + chipGridInstance._keydown(BACKSPACE_EVENT); + fixture.detectChanges(); + + // It focuses the last chip + expect(manager.activeRowIndex).toEqual(chips.length - 1); + expect(manager.activeColumnIndex).toBe(0); + }); + }); + }); + + 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(); + }); + + it('should point the label id to the chip input', () => { + const label = fixture.nativeElement.querySelector('label'); + const input = fixture.nativeElement.querySelector('input'); + + fixture.detectChanges(); + + expect(label.getAttribute('for')).toBeTruthy(); + expect(label.getAttribute('for')).toBe(input.getAttribute('id')); + expect(label.getAttribute('aria-owns')).toBe(input.getAttribute('id')); + }); + }); + + describe('with chip remove', () => { + let chipGrid: MatChipGrid; + let chipElements: DebugElement[]; + let chipRemoveDebugElements: DebugElement[]; + + beforeEach(() => { + fixture = createComponent(ChipGridWithRemove); + fixture.detectChanges(); + + chipGrid = fixture.debugElement.query(By.directive(MatChipGrid)).componentInstance; + chipElements = fixture.debugElement.queryAll(By.directive(MatChipRow)); + chipRemoveDebugElements = fixture.debugElement.queryAll(By.directive(MatChipRemove)); + chips = chipGrid._chips; + }); + + it('should properly focus next item if chip is removed through click', () => { + chips.toArray()[2].focus(); + + // Destroy the third focused chip by dispatching a bubbling click event on the + // associated chip remove element. + dispatchMouseEvent(chipRemoveDebugElements[2].nativeElement, 'click'); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), {propertyName: 'width'}); + chipElements[2].nativeElement.dispatchEvent(fakeEvent); + + fixture.detectChanges(); + + expect(chips.toArray()[2].value).not.toBe(2, 'Expected the third chip to be removed.'); + expect(chipGrid._keyManager.activeRowIndex).toBe(2); + }); + }); + + describe('chip grid with chip input', () => { + let nativeChips: HTMLElement[]; + + beforeEach(() => { + fixture = createComponent(InputChipGrid); + fixture.detectChanges(); + + nativeChips = fixture.debugElement.queryAll(By.css('mat-chip-row')) + .map((chip) => chip.nativeElement); + }); + + it('should take an initial view value with reactive forms', () => { + fixture.componentInstance.control = new FormControl('[pizza-1]'); + 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).toBeFalsy('Expect chip grid to have no initial value'); + + 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) + .toEqual(null, `Expected the control's value to be empty initially.`); + + const nativeInput = fixture.nativeElement.querySelector('input'); + // tick(); + nativeInput.focus(); + + typeInElement('123', nativeInput); + fixture.detectChanges(); + dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); + fixture.detectChanges(); + tick(); + + dispatchFakeEvent(nativeInput, 'blur'); + tick(); + + 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) + .toBe(false, 'Expected the control to start off as untouched.'); + + const nativeChipGrid = fixture.debugElement.query(By.css('mat-chip-grid')).nativeElement; + dispatchFakeEvent(nativeChipGrid, 'blur'); + tick(); + + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched.'); + })); + + it('should not set touched when a disabled chip grid is touched', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + const nativeChipGrid = fixture.debugElement.query(By.css('mat-chip-grid')).nativeElement; + dispatchFakeEvent(nativeChipGrid, 'blur'); + tick(); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + })); + + it('should set the control to dirty when the chip grid\'s value changes in the DOM', + fakeAsync(() => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + const nativeInput = fixture.nativeElement.querySelector('input'); + nativeInput.focus(); + + typeInElement('123', nativeInput); + fixture.detectChanges(); + dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); + fixture.detectChanges(); + tick(); + + dispatchFakeEvent(nativeInput, 'blur'); + tick(); + + expect(fixture.componentInstance.control.dirty) + .toEqual(true, `Expected control to be dirty after value was changed by user.`); + })); + + it('should not set the control to dirty when the value changes programmatically', () => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + fixture.componentInstance.control.setValue(['pizza-1']); + + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to stay pristine after programmatic change.`); + }); + + it('should set an asterisk after the placeholder if the control is required', () => { + let requiredMarker = fixture.debugElement.query(By.css('.mat-form-field-required-marker')); + expect(requiredMarker) + .toBeNull(`Expected placeholder not to have an asterisk, as control was not required.`); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + requiredMarker = fixture.debugElement.query(By.css('.mat-form-field-required-marker')); + expect(requiredMarker) + .not.toBeNull(`Expected placeholder to have an asterisk, as control was required.`); + }); + + it('should blur the form field when the active chip is blurred', fakeAsync(() => { + const formField: HTMLElement = fixture.nativeElement.querySelector('.mat-form-field'); + + dispatchFakeEvent(nativeChips[0], 'focusin'); + fixture.detectChanges(); + + expect(formField.classList).toContain('mat-focused'); + + dispatchFakeEvent(nativeChips[0], 'focusout'); + fixture.detectChanges(); + zone.simulateZoneExit(); + fixture.detectChanges(); + tick(); + expect(formField.classList).not.toContain('mat-focused'); + })); + + it('should keep focus on the input after adding the first chip', fakeAsync(() => { + const nativeInput = fixture.nativeElement.querySelector('input'); + 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(); + const fakeEvent = Object.assign(createFakeEvent('transitionend'), {propertyName: 'width'}); + chip.dispatchEvent(fakeEvent); + fixture.detectChanges(); + tick(); + }); + + nativeInput.focus(); + expect(fixture.componentInstance.foods).toEqual([], 'Expected all chips to be removed.'); + expect(document.activeElement).toBe(nativeInput, 'Expected input to be focused.'); + + typeInElement('123', nativeInput); + fixture.detectChanges(); + dispatchKeyboardEvent(nativeInput, 'keydown', ENTER); + fixture.detectChanges(); + tick(); + + expect(document.activeElement).toBe(nativeInput, 'Expected input to remain focused.'); + })); + + it('should set aria-invalid if the form field is invalid', fakeAsync(() => { + fixture.componentInstance.control = new FormControl(undefined, [Validators.required]); + fixture.detectChanges(); + + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + + typeInElement('123', input); + fixture.detectChanges(); + dispatchKeyboardEvent(input, 'keydown', ENTER); + fixture.detectChanges(); + tick(); + + dispatchFakeEvent(input, 'blur'); + tick(); + + fixture.detectChanges(); + expect(input.getAttribute('aria-invalid')).toBe('false'); + })); + }); + + describe('error messages', () => { + let errorTestComponent: ChipGridWithFormErrorMessages; + let containerEl: HTMLElement; + let chipGridEl: HTMLElement; + + beforeEach(() => { + fixture = createComponent(ChipGridWithFormErrorMessages); + 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; + }); + + it('should not show any errors if the user has not interacted', () => { + expect(errorTestComponent.formControl.untouched) + .toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + expect(chipGridEl.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to "false".'); + }); + + it('should display an error message when the grid is touched and invalid', fakeAsync(() => { + expect(errorTestComponent.formControl.invalid) + .toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(0, 'Expected no error message'); + + errorTestComponent.formControl.markAsTouched(); + fixture.detectChanges(); + tick(); + + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(chipGridEl.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to "true".'); + })); + + it('should display an error message when the parent form is submitted', fakeAsync(() => { + expect(errorTestComponent.form.submitted) + .toBe(false, 'Expected form not to have been submitted'); + expect(errorTestComponent.formControl.invalid) + .toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + + dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(errorTestComponent.form.submitted) + .toBe(true, 'Expected form to have been submitted'); + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(chipGridEl.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to "true".'); + }); + })); + + it('should hide the errors and show the hints once the chip grid becomes valid', + fakeAsync(() => { + errorTestComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(0, 'Expected no hints to be shown.'); + + errorTestComponent.formControl.setValue('something'); + 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) + .toBe(0, 'Expected no error messages when the input is valid.'); + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(1, 'Expected one hint to be shown once the input is valid.'); + }); + }); + })); + + it('should set the proper role on the error messages', () => { + errorTestComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelector('mat-error')!.getAttribute('role')).toBe('alert'); + }); + + it('sets the aria-describedby to reference errors when in error state', () => { + let hintId = fixture.debugElement.query(By.css('.mat-hint')).nativeElement.getAttribute('id'); + let describedBy = chipGridEl.getAttribute('aria-describedby'); + + expect(hintId).toBeTruthy('hint should be shown'); + expect(describedBy).toBe(hintId); + + fixture.componentInstance.formControl.markAsTouched(); + fixture.detectChanges(); + + let errorIds = fixture.debugElement.queryAll(By.css('.mat-error')) + .map(el => el.nativeElement.getAttribute('id')).join(' '); + describedBy = chipGridEl.getAttribute('aria-describedby'); + + expect(errorIds).toBeTruthy('errors should be shown'); + expect(describedBy).toBe(errorIds); + }); + }); + + function createComponent(component: Type, providers: Provider[] = [], animationsModule: + Type | Type = NoopAnimationsModule): + ComponentFixture { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + MatChipsModule, + MatFormFieldModule, + MatInputModule, + animationsModule, + ], + declarations: [component], + providers: [ + {provide: NgZone, useFactory: () => zone = new MockNgZone()}, + ...providers + ] + }).compileComponents(); + + return TestBed.createComponent(component); + } + + function setupStandardGrid(direction: Direction = 'ltr') { + dirChange = new Subject(); + fixture = createComponent(StandardChipGrid, [{ + provide: Directionality, useFactory: () => ({ + value: direction.toLowerCase(), + change: dirChange + }) + }]); + fixture.detectChanges(); + + chipGridDebugElement = fixture.debugElement.query(By.directive(MatChipGrid)); + chipGridNativeElement = chipGridDebugElement.nativeElement; + chipGridInstance = chipGridDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + chips = chipGridInstance._chips; + } + + function setupInputGrid() { + fixture = createComponent(FormFieldChipGrid); + fixture.detectChanges(); + + chipGridDebugElement = fixture.debugElement.query(By.directive(MatChipGrid)); + chipGridNativeElement = chipGridDebugElement.nativeElement; + chipGridInstance = chipGridDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + chips = chipGridInstance._chips; + } +}); + +@Component({ + template: ` + + + {{name}} {{i + 1}} + + + ` +}) +class StandardChipGrid { + name: string = 'Test'; + tabIndex: number = 0; + chips = [0, 1, 2, 3, 4]; +} + +@Component({ + template: ` + + Add a chip + + {{chip}} + + + + ` +}) +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: ` + + + + {{ food.viewValue }} + + + + + ` +}) +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(); + + separatorKeyCodes = [ENTER, SPACE]; + addOnBlur: boolean = true; + isRequired: boolean; + + add(event: MatChipInputEvent): void { + let input = event.input; + let value = event.value; + + // Add our foods + if ((value || '').trim()) { + this.foods.push({ + value: `${value.trim().toLowerCase()}-${this.foods.length}`, + viewValue: value.trim() + }); + } + + // Reset the input value + if (input) { + input.value = ''; + } + } + + remove(food: any): void { + const index = this.foods.indexOf(food); + + if (index > -1) { + this.foods.splice(index, 1); + } + } + + @ViewChild(MatChipGrid, {static: false}) chipGrid: MatChipGrid; + @ViewChildren(MatChipRow) chips: QueryList; +} + +@Component({ + template: ` +
+ + + + {{food.viewValue}} + + + + Please select a chip, or type to add a new chip + Should have value + +
+ ` +}) +class ChipGridWithFormErrorMessages { + foods: any[] = [ + {value: 0, viewValue: 'Steak'}, + {value: 1, viewValue: 'Pizza'}, + {value: 2, viewValue: 'Pasta'}, + ]; + @ViewChildren(MatChipRow) chips: QueryList; + + @ViewChild('form', {static: false}) form: NgForm; + formControl = new FormControl('', Validators.required); +} + +@Component({ + template: ` + + {{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})) + ]) + ]) + ] +}) +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: ` + + + + Chip {{i + 1}} + Remove + + + + + ` +}) +class ChipGridWithRemove { + chips = [0, 1, 2, 3, 4]; + + removeChip(event: MatChipEvent) { + this.chips.splice(event.chip.value, 1); + } +} diff --git a/src/material-experimental/mdc-chips/chip-grid.ts b/src/material-experimental/mdc-chips/chip-grid.ts index cf7e3f13c988..89fea1ed8c93 100644 --- a/src/material-experimental/mdc-chips/chip-grid.ts +++ b/src/material-experimental/mdc-chips/chip-grid.ts @@ -83,8 +83,8 @@ const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase = inputs: ['tabIndex'], host: { 'class': 'mat-mdc-chip-set mat-mdc-chip-grid mdc-chip-set', - 'role': 'grid', - '[tabIndex]': 'tabIndex', + '[attr.role]': 'role', + '[tabIndex]': '_chips && _chips.length === 0 ? -1 : tabIndex', // TODO: replace this binding with use of AriaDescriber '[attr.aria-describedby]': '_ariaDescribedby || null', '[attr.aria-required]': 'required.toString()', @@ -157,12 +157,23 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn */ get empty(): boolean { return this._chipInput.empty && this._chips.length === 0; } + /** The ARIA role applied to the chip grid. */ + get role(): string | null { return this.empty ? null : 'grid'; } + /** * Implemented as part of MatFormFieldControl. * @docs-private */ @Input() - get placeholder(): string { return this._chipInput.placeholder; } + @Input() + get placeholder(): string { + return this._chipInput ? this._chipInput.placeholder : this._placeholder; + } + set placeholder(value: string) { + this._placeholder = value; + this.stateChanges.next(); + } + protected _placeholder: string; /** Whether any chips or the matChipInput inside of this chip-grid has focus. */ get focused(): boolean { return this._chipInput.focused || this._hasFocusedChip(); } @@ -222,7 +233,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn // indirect descendants if it's left as false. descendants: true }) - _rowChips: QueryList; + _chips: QueryList; constructor(_elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, @@ -244,8 +255,6 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn this._initKeyManager(); this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { - this._updateTabIndex(); - // Check to see if we have a destroyed chip and need to refocus this._updateFocusForDestroyedChips(); @@ -339,6 +348,15 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn this._onTouched = fn; } + /** + * Implemented as part of ControlValueAccessor. + * @docs-private + */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.stateChanges.next(); + } + /** When blurred, mark the field as touched when focus moved outside the chip grid. */ _blur() { if (this.disabled) { @@ -368,11 +386,13 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn return; } + const previousTabIndex = this.tabIndex; + if (this.tabIndex !== -1) { this.tabIndex = -1; setTimeout(() => { - this.tabIndex = 0; + this.tabIndex = previousTabIndex; this._changeDetectorRef.markForCheck(); }); } @@ -388,9 +408,9 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn this._keyManager.setLastCellActive(); } event.preventDefault(); - } else if (event.keyCode === TAB) { + } else if (event.keyCode === TAB && target.id !== this._chipInput!.id ) { this._allowFocusEscape(); - } else { + } else if (this._originatesFromChip(event)) { this._keyManager.onKeydown(event); } this.stateChanges.next(); @@ -419,7 +439,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn /** Initializes the key manager to manage focus. */ private _initKeyManager() { - this._keyManager = new GridFocusKeyManager(this._rowChips) + this._keyManager = new GridFocusKeyManager(this._chips) .withDirectionality(this._dir ? this._dir.value : 'ltr'); if (this._dir) { @@ -432,7 +452,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn /** Subscribes to chip focus events. */ private _listenToChipsFocus(): void { this._chipFocusSubscription = this.chipFocusChanges.subscribe((event: MatChipEvent) => { - let chipIndex: number = this._chips.toArray().indexOf(event.chip); + let chipIndex: number = this._chips.toArray().indexOf(event.chip as MatChipRow); if (this._isValidIndex(chipIndex)) { this._keyManager.updateActiveCell({row: chipIndex, column: 0}); @@ -466,27 +486,10 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn this.stateChanges.next(); } - /** Checks whether an event comes from inside a chip element. */ - private _originatesFromChip(event: Event): boolean { - let currentElement = event.target as HTMLElement | null; - - while (currentElement && currentElement !== this._elementRef.nativeElement) { - if (currentElement.classList.contains('mdc-chip')) { - return true; - } - - currentElement = currentElement.parentElement; - } - - return false; - } - /** * If the amount of chips changed, we need to focus the next closest chip. */ private _updateFocusForDestroyedChips() { - // Wait for chips to be updated in keyManager - setTimeout(() => { // Move focus to the closest chip. If no other chips remain, focus the chip-grid itself. if (this._lastDestroyedChipIndex != null) { if (this._chips.length) { @@ -501,7 +504,6 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn } this._lastDestroyedChipIndex = null; - }); } /** Focus input element. */ @@ -518,12 +520,4 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn return false; } - - /** - * Check the tab index as you should not be allowed to focus an empty grid. - */ - protected _updateTabIndex(): void { - // If we have 0 chips, we should not allow keyboard focus - this.tabIndex = this._chips.length === 0 ? -1 : 0; - } } diff --git a/src/material-experimental/mdc-chips/chip-icons.ts b/src/material-experimental/mdc-chips/chip-icons.ts index fbec9c5c9c2d..b2b5fbc36952 100644 --- a/src/material-experimental/mdc-chips/chip-icons.ts +++ b/src/material-experimental/mdc-chips/chip-icons.ts @@ -93,7 +93,7 @@ const _MatChipRemoveMixinBase: selector: '[matChipRemove]', inputs: ['disabled', 'tabIndex'], host: { - 'class': 'mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing', + 'class': 'mat-chip-remove mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing', '[tabIndex]': 'tabIndex', 'role': 'button', '(click)': 'interaction.next($event)', diff --git a/src/material-experimental/mdc-chips/chip-input.spec.ts b/src/material-experimental/mdc-chips/chip-input.spec.ts new file mode 100644 index 000000000000..228774e0a6bf --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-input.spec.ts @@ -0,0 +1,264 @@ +import {Directionality} from '@angular/cdk/bidi'; +import {ENTER, COMMA, TAB} from '@angular/cdk/keycodes'; +import {PlatformModule} from '@angular/cdk/platform'; +import {createKeyboardEvent, dispatchKeyboardEvent, dispatchEvent} from '@angular/cdk/testing'; +import {Component, DebugElement, ViewChild} from '@angular/core'; +import {async, ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {Subject} from 'rxjs'; +import { + MAT_CHIPS_DEFAULT_OPTIONS, + MatChipInput, + MatChipInputEvent, + MatChipGrid, + MatChipsDefaultOptions, + MatChipsModule +} from './index'; + + +describe('MatChipInput', () => { + let fixture: ComponentFixture; + let testChipInput: TestChipInput; + let inputDebugElement: DebugElement; + let inputNativeElement: HTMLElement; + let chipInputDirective: MatChipInput; + let dir = 'ltr'; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [PlatformModule, MatChipsModule, MatFormFieldModule, NoopAnimationsModule], + declarations: [TestChipInput], + providers: [{ + provide: Directionality, useFactory: () => { + return { + value: dir.toLowerCase(), + change: new Subject() + }; + } + }] + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(TestChipInput); + testChipInput = fixture.debugElement.componentInstance; + fixture.detectChanges(); + + inputDebugElement = fixture.debugElement.query(By.directive(MatChipInput)); + chipInputDirective = inputDebugElement.injector.get(MatChipInput); + inputNativeElement = inputDebugElement.nativeElement; + })); + + describe('basic behavior', () => { + it('emits the (chipEnd) on enter keyup', () => { + let ENTER_EVENT = createKeyboardEvent('keydown', ENTER, inputNativeElement); + + spyOn(testChipInput, 'add'); + + chipInputDirective._keydown(ENTER_EVENT); + expect(testChipInput.add).toHaveBeenCalled(); + }); + + it('should have a default id', () => { + expect(inputNativeElement.getAttribute('id')).toBeTruthy(); + }); + + it('should allow binding to the `placeholder` input', () => { + expect(inputNativeElement.hasAttribute('placeholder')).toBe(false); + + testChipInput.placeholder = 'bound placeholder'; + fixture.detectChanges(); + + expect(inputNativeElement.getAttribute('placeholder')).toBe('bound placeholder'); + }); + + it('should propagate the dynamic `placeholder` value to the form field', () => { + fixture.componentInstance.placeholder = 'add a chip'; + fixture.detectChanges(); + + const label: HTMLElement = fixture.nativeElement.querySelector('.mat-form-field-label'); + + expect(label).toBeTruthy(); + expect(label.textContent).toContain('add a chip'); + + fixture.componentInstance.placeholder = 'or don\'t'; + fixture.detectChanges(); + + expect(label.textContent).toContain('or don\'t'); + }); + + it('should become disabled if the chip list is disabled', () => { + expect(inputNativeElement.hasAttribute('disabled')).toBe(false); + expect(chipInputDirective.disabled).toBe(false); + + fixture.componentInstance.chipGridInstance.disabled = true; + fixture.detectChanges(); + + expect(inputNativeElement.getAttribute('disabled')).toBe('true'); + expect(chipInputDirective.disabled).toBe(true); + }); + + it('should allow focus to escape when tabbing forwards', fakeAsync(() => { + const gridElement: HTMLElement = fixture.nativeElement.querySelector('mat-chip-grid'); + + expect(gridElement.getAttribute('tabindex')).toBe('0'); + + dispatchKeyboardEvent(inputNativeElement, 'keydown', TAB, inputNativeElement); + fixture.detectChanges(); + + expect(gridElement.getAttribute('tabindex')) + .toBe('-1', 'Expected tabIndex to be set to -1 temporarily.'); + + tick(); + fixture.detectChanges(); + + expect(gridElement.getAttribute('tabindex')) + .toBe('0', 'Expected tabIndex to be reset back to 0'); + })); + + it('should not allow focus to escape when tabbing backwards', fakeAsync(() => { + const gridElement: HTMLElement = fixture.nativeElement.querySelector('mat-chip-grid'); + const event = createKeyboardEvent('keydown', TAB, inputNativeElement); + Object.defineProperty(event, 'shiftKey', {get: () => true}); + + expect(gridElement.getAttribute('tabindex')).toBe('0'); + + dispatchEvent(inputNativeElement, event); + fixture.detectChanges(); + + expect(gridElement.getAttribute('tabindex')).toBe('0', 'Expected tabindex to remain 0'); + + tick(); + fixture.detectChanges(); + + expect(gridElement.getAttribute('tabindex')).toBe('0', 'Expected tabindex to remain 0'); + })); + + }); + + describe('[addOnBlur]', () => { + it('allows (chipEnd) when true', () => { + spyOn(testChipInput, 'add'); + + testChipInput.addOnBlur = true; + fixture.detectChanges(); + + chipInputDirective._blur(); + expect(testChipInput.add).toHaveBeenCalled(); + }); + + it('disallows (chipEnd) when false', () => { + spyOn(testChipInput, 'add'); + + testChipInput.addOnBlur = false; + fixture.detectChanges(); + + chipInputDirective._blur(); + expect(testChipInput.add).not.toHaveBeenCalled(); + }); + }); + + describe('[separatorKeyCodes]', () => { + it('does not emit (chipEnd) when a non-separator key is pressed', () => { + let ENTER_EVENT = createKeyboardEvent('keydown', ENTER, inputNativeElement); + spyOn(testChipInput, 'add'); + + chipInputDirective.separatorKeyCodes = [COMMA]; + fixture.detectChanges(); + + chipInputDirective._keydown(ENTER_EVENT); + expect(testChipInput.add).not.toHaveBeenCalled(); + }); + + it('emits (chipEnd) when a custom separator keys is pressed', () => { + let COMMA_EVENT = createKeyboardEvent('keydown', COMMA, inputNativeElement); + spyOn(testChipInput, 'add'); + + chipInputDirective.separatorKeyCodes = [COMMA]; + fixture.detectChanges(); + + chipInputDirective._keydown(COMMA_EVENT); + expect(testChipInput.add).toHaveBeenCalled(); + }); + + it('emits accepts the custom separator keys in a Set', () => { + let COMMA_EVENT = createKeyboardEvent('keydown', COMMA, inputNativeElement); + spyOn(testChipInput, 'add'); + + chipInputDirective.separatorKeyCodes = new Set([COMMA]); + fixture.detectChanges(); + + chipInputDirective._keydown(COMMA_EVENT); + expect(testChipInput.add).toHaveBeenCalled(); + }); + + it('emits (chipEnd) when the separator keys are configured globally', () => { + fixture.destroy(); + + TestBed + .resetTestingModule() + .configureTestingModule({ + imports: [MatChipsModule, MatFormFieldModule, PlatformModule, NoopAnimationsModule], + declarations: [TestChipInput], + providers: [{ + provide: MAT_CHIPS_DEFAULT_OPTIONS, + useValue: ({separatorKeyCodes: [COMMA]} as MatChipsDefaultOptions) + }] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TestChipInput); + testChipInput = fixture.debugElement.componentInstance; + fixture.detectChanges(); + + inputDebugElement = fixture.debugElement.query(By.directive(MatChipInput)); + chipInputDirective = inputDebugElement.injector.get(MatChipInput); + inputNativeElement = inputDebugElement.nativeElement; + + spyOn(testChipInput, 'add'); + fixture.detectChanges(); + + chipInputDirective._keydown(createKeyboardEvent('keydown', COMMA, inputNativeElement)); + expect(testChipInput.add).toHaveBeenCalled(); + }); + + it('should not emit the chipEnd event if a separator is pressed with a modifier key', () => { + const ENTER_EVENT = createKeyboardEvent('keydown', ENTER, inputNativeElement); + Object.defineProperty(ENTER_EVENT, 'shiftKey', {get: () => true}); + spyOn(testChipInput, 'add'); + + chipInputDirective.separatorKeyCodes = [ENTER]; + fixture.detectChanges(); + + chipInputDirective._keydown(ENTER_EVENT); + expect(testChipInput.add).not.toHaveBeenCalled(); + }); + + }); +}); + +@Component({ + template: ` + + + Hello + + + + ` +}) +class TestChipInput { + @ViewChild(MatChipGrid, {static: false}) chipGridInstance: MatChipGrid; + addOnBlur: boolean = false; + placeholder = ''; + + add(_: MatChipInputEvent) { + } +} diff --git a/src/material-experimental/mdc-chips/chip-input.ts b/src/material-experimental/mdc-chips/chip-input.ts index b481364ff794..8edfce5ca609 100644 --- a/src/material-experimental/mdc-chips/chip-input.ts +++ b/src/material-experimental/mdc-chips/chip-input.ts @@ -171,4 +171,3 @@ export class MatChipInput implements MatChipTextControl, OnChanges { return Array.isArray(separators) ? separators.indexOf(keyCode) > -1 : separators.has(keyCode); } } - diff --git a/src/material-experimental/mdc-chips/chip-listbox.spec.ts b/src/material-experimental/mdc-chips/chip-listbox.spec.ts new file mode 100644 index 000000000000..a3dadda74cb1 --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-listbox.spec.ts @@ -0,0 +1,900 @@ +import {FocusKeyManager} from '@angular/cdk/a11y'; +import {Directionality, Direction} from '@angular/cdk/bidi'; +import { + END, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, +} from '@angular/cdk/keycodes'; +import { + createKeyboardEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + MockNgZone, +} from '@angular/cdk/testing'; +import { + Component, + DebugElement, + NgZone, + Provider, + QueryList, + Type, + ViewChild, + ViewChildren +} from '@angular/core'; +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {Subject} from 'rxjs'; +import {MatChip, MatChipListbox, MatChipOption, MatChipsModule} from './index'; + + +describe('MatChipListbox', () => { + let fixture: ComponentFixture; + let chipListboxDebugElement: DebugElement; + let chipListboxNativeElement: HTMLElement; + let chipListboxInstance: MatChipListbox; + let testComponent: StandardChipListbox; + let chips: QueryList; + let manager: FocusKeyManager; + let zone: MockNgZone; + let dirChange: Subject; + + describe('StandardChipList', () => { + describe('basic behaviors', () => { + + beforeEach(() => { + setupStandardListbox(); + }); + + it('should add the `mat-mdc-chip-set` class', () => { + expect(chipListboxNativeElement.classList).toContain('mat-mdc-chip-set'); + }); + + it('should not have the aria-selected attribute when it is not selectable', fakeAsync(() => { + testComponent.selectable = false; + fixture.detectChanges(); + tick(); + + const chipsValid = chips.toArray().every(chip => + !chip.selectable && !chip._elementRef.nativeElement.hasAttribute('aria-selected')); + + expect(chipsValid).toBe(true); + })); + + it('should toggle the chips disabled state based on whether it is disabled', () => { + expect(chips.toArray().every(chip => chip.disabled)).toBe(false); + + chipListboxInstance.disabled = true; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + chipListboxInstance.disabled = false; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(false); + }); + + it('should disable a chip that is added after the listbox became disabled', fakeAsync(() => { + expect(chips.toArray().every(chip => chip.disabled)).toBe(false); + + chipListboxInstance.disabled = true; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + fixture.componentInstance.chips.push(5, 6); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + })); + }); + + describe('with selected chips', () => { + beforeEach(() => { + fixture = createComponent(SelectedChipListbox); + fixture.detectChanges(); + chipListboxDebugElement = fixture.debugElement.query(By.directive(MatChipListbox)); + chipListboxNativeElement = chipListboxDebugElement.nativeElement; + }); + + it('should not override chips selected', () => { + const instanceChips = fixture.componentInstance.chips.toArray(); + + expect(instanceChips[0].selected).toBe(true, 'Expected first option to be selected.'); + expect(instanceChips[1].selected).toBe(false, 'Expected second option to be not selected.'); + expect(instanceChips[2].selected).toBe(true, 'Expected third option to be selected.'); + }); + + it('should have role listbox', () => { + expect(chipListboxNativeElement.getAttribute('role')).toBe('listbox'); + }); + + it('should not have role when empty', () => { + fixture.componentInstance.foods = []; + fixture.detectChanges(); + + expect(chipListboxNativeElement.getAttribute('role')).toBeNull('Expect no role attribute'); + }); + }); + + describe('focus behaviors', () => { + + beforeEach(() => { + setupStandardListbox(); + manager = chipListboxInstance._keyManager; + }); + + it('should focus the first chip on focus', () => { + chipListboxInstance.focus(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(0); + }); + + it('should watch for chip focus', () => { + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + lastItem.focus(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(lastIndex); + }); + + it('should not be able to become focused when disabled', () => { + expect(chipListboxInstance.focused).toBe(false, 'Expected listbox to not be focused.'); + + chipListboxInstance.disabled = true; + fixture.detectChanges(); + + chipListboxInstance.focus(); + fixture.detectChanges(); + + expect(chipListboxInstance.focused).toBe(false, + 'Expected listbox to continue not to be focused'); + }); + + it('should remove the tabindex from the listbox if it is disabled', () => { + expect(chipListboxNativeElement.getAttribute('tabindex')).toBe('0'); + + chipListboxInstance.disabled = true; + fixture.detectChanges(); + + expect(chipListboxNativeElement.getAttribute('tabindex')).toBe('-1'); + }); + + describe('on chip destroy', () => { + + it('should focus the next item', () => { + let array = chips.toArray(); + let midItem = array[2]; + + // Focus the middle item + midItem.focus(); + + // Destroy the middle item + testComponent.chips.splice(2, 1); + fixture.detectChanges(); + + // It focuses the 4th item (now at index 2) + expect(manager.activeItemIndex).toEqual(2); + }); + + it('should focus the previous item', () => { + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + // Focus the last item + lastItem.focus(); + + // Destroy the last item + testComponent.chips.pop(); + fixture.detectChanges(); + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(lastIndex - 1); + }); + + it('should not focus if chip listbox is not focused', () => { + let array = chips.toArray(); + let midItem = array[2]; + + // Focus and blur the middle item + midItem.focus(); + midItem._blur(); + zone.simulateZoneExit(); + + // Destroy the middle item + testComponent.chips.splice(2, 1); + fixture.detectChanges(); + + // Should not have focus + expect(chipListboxInstance._keyManager.activeItemIndex).toEqual(-1); + }); + + it('should focus the listbox if the last focused item is removed', () => { + testComponent.chips = [0]; + fixture.detectChanges(); + + spyOn(chipListboxInstance, 'focus'); + chips.last.focus(); + + testComponent.chips.pop(); + fixture.detectChanges(); + + expect(chipListboxInstance.focus).toHaveBeenCalled(); + }); + }); + }); + + describe('keyboard behavior', () => { + describe('LTR (default)', () => { + beforeEach(() => { + setupStandardListbox(); + manager = chipListboxInstance._keyManager; + }); + + it('should focus previous item when press LEFT ARROW', () => { + let nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + + let LEFT_EVENT = createKeyboardEvent('keydown', LEFT_ARROW, lastNativeChip); + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + // Focus the last item in the array + lastItem.focus(); + expect(manager.activeItemIndex).toEqual(lastIndex); + + // Press the LEFT arrow + chipListboxInstance._keydown(LEFT_EVENT); + chipListboxInstance._blur(); // Simulate focus leaving the listbox and going to the chip. + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(lastIndex - 1); + }); + + it('should focus next item when press RIGHT ARROW', () => { + let nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + // Focus the last item in the array + firstItem.focus(); + expect(manager.activeItemIndex).toEqual(0); + + // Press the RIGHT arrow + chipListboxInstance._keydown(RIGHT_EVENT); + chipListboxInstance._blur(); // Simulate focus leaving the listbox and going to the chip. + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(1); + }); + + it('should not handle arrow key events from non-chip elements', () => { + const event: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, chipListboxNativeElement); + const initialActiveIndex = manager.activeItemIndex; + + chipListboxInstance._keydown(event); + fixture.detectChanges(); + + expect(manager.activeItemIndex) + .toBe(initialActiveIndex, 'Expected focused item not to have changed.'); + }); + + it('should focus the first item when pressing HOME', () => { + const nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + const lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + const HOME_EVENT = createKeyboardEvent('keydown', HOME, lastNativeChip); + const array = chips.toArray(); + const lastItem = array[array.length - 1]; + + lastItem.focus(); + expect(manager.activeItemIndex).toBe(array.length - 1); + + chipListboxInstance._keydown(HOME_EVENT); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(0); + expect(HOME_EVENT.defaultPrevented).toBe(true); + }); + + it('should focus the last item when pressing END', () => { + const nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + const END_EVENT = createKeyboardEvent('keydown', END, nativeChips[0]); + + expect(manager.activeItemIndex).toBe(-1); + + chipListboxInstance._keydown(END_EVENT); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(chips.length - 1); + expect(END_EVENT.defaultPrevented).toBe(true); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupStandardListbox('rtl'); + manager = chipListboxInstance._keyManager; + }); + + it('should focus previous item when press RIGHT ARROW', () => { + let nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, lastNativeChip); + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + // Focus the last item in the array + lastItem.focus(); + expect(manager.activeItemIndex).toEqual(lastIndex); + + // Press the RIGHT arrow + chipListboxInstance._keydown(RIGHT_EVENT); + chipListboxInstance._blur(); // Simulate focus leaving the listbox and going to the chip. + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(lastIndex - 1); + }); + + it('should focus next item when press LEFT ARROW', () => { + let nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let LEFT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', LEFT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + // Focus the last item in the array + firstItem.focus(); + expect(manager.activeItemIndex).toEqual(0); + + // Press the LEFT arrow + chipListboxInstance._keydown(LEFT_EVENT); + chipListboxInstance._blur(); // Simulate focus leaving the listbox and going to the chip. + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(1); + }); + + it('should allow focus to escape when tabbing away', fakeAsync(() => { + chipListboxInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB)); + + expect(chipListboxInstance.tabIndex) + .toBe(-1, 'Expected tabIndex to be set to -1 temporarily.'); + + tick(); + + expect(chipListboxInstance.tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0'); + })); + + it(`should use user defined tabIndex`, fakeAsync(() => { + chipListboxInstance.tabIndex = 4; + + fixture.detectChanges(); + + expect(chipListboxInstance.tabIndex) + .toBe(4, 'Expected tabIndex to be set to user defined value 4.'); + + chipListboxInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB)); + + expect(chipListboxInstance.tabIndex) + .toBe(-1, 'Expected tabIndex to be set to -1 temporarily.'); + + tick(); + + expect(chipListboxInstance.tabIndex).toBe(4, 'Expected tabIndex to be reset back to 4'); + })); + }); + + it('should account for the direction changing', () => { + setupStandardListbox(); + manager = chipListboxInstance._keyManager; + + let nativeChips = chipListboxNativeElement.querySelectorAll('mat-chip-option'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let RIGHT_EVENT: KeyboardEvent = + createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip); + let array = chips.toArray(); + let firstItem = array[0]; + + firstItem.focus(); + expect(manager.activeItemIndex).toBe(0); + + chipListboxInstance._keydown(RIGHT_EVENT); + chipListboxInstance._blur(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(1); + + dirChange.next('rtl'); + fixture.detectChanges(); + + chipListboxInstance._keydown(RIGHT_EVENT); + chipListboxInstance._blur(); + fixture.detectChanges(); + + expect(manager.activeItemIndex).toBe(0); + }); + }); + + describe('selection logic', () => { + let nativeChips: HTMLElement[]; + + beforeEach(() => { + fixture = createComponent(BasicChipListbox); + fixture.detectChanges(); + + nativeChips = fixture.debugElement.queryAll(By.css('mat-chip-option')) + .map((chip) => chip.nativeElement); + + chipListboxDebugElement = fixture.debugElement.query(By.directive(MatChipListbox)); + chipListboxInstance = chipListboxDebugElement.componentInstance; + chips = chipListboxInstance._chips; + + }); + + it('should remove selection if chip has been removed', fakeAsync(() => { + const instanceChips = fixture.componentInstance.chips; + const chipListbox = fixture.componentInstance.chipListbox; + const firstChip = nativeChips[0]; + dispatchKeyboardEvent(firstChip, 'keydown', SPACE); + fixture.detectChanges(); + + expect(instanceChips.first.selected).toBe(true, 'Expected first option to be selected.'); + expect(chipListbox.selected).toBe(chips.first, 'Expected first option to be selected.'); + + fixture.componentInstance.foods = []; + fixture.detectChanges(); + tick(); + + expect(chipListbox.selected) + .toBe(undefined, 'Expected selection to be removed when option no longer exists.'); + })); + + + it('should select an option that was added after initialization', () => { + fixture.componentInstance.foods.push({viewValue: 'Potatoes', value: 'potatoes-8'}); + fixture.detectChanges(); + + nativeChips = fixture.debugElement.queryAll(By.css('mat-chip-option')) + .map((chip) => chip.nativeElement); + const lastChip = nativeChips[8]; + dispatchKeyboardEvent(lastChip, 'keydown', SPACE); + fixture.detectChanges(); + + expect(fixture.componentInstance.chipListbox.value) + .toContain('potatoes-8', 'Expect value contain the value of the last option'); + expect(fixture.componentInstance.chips.last.selected) + .toBeTruthy('Expect last option selected'); + }); + + it('should not select disabled chips', () => { + const array = chips.toArray(); + const disabledChip = nativeChips[2]; + dispatchKeyboardEvent(disabledChip, 'keydown', SPACE); + fixture.detectChanges(); + + expect(fixture.componentInstance.chipListbox.value) + .toBeUndefined('Expect value to be undefined'); + expect(array[2].selected).toBeFalsy('Expect disabled chip not selected'); + expect(fixture.componentInstance.chipListbox.selected) + .toBeUndefined('Expect no selected chips'); + }); + }); + + describe('chip list with chip input', () => { + let nativeChips: HTMLElement[]; + + describe('single selection', () => { + beforeEach(() => { + fixture = createComponent(BasicChipListbox); + fixture.detectChanges(); + + nativeChips = fixture.debugElement.queryAll(By.css('mat-chip-option')) + .map((chip) => chip.nativeElement); + chips = fixture.componentInstance.chips; + }); + + it('should take an initial view value with reactive forms', fakeAsync(() => { + fixture.componentInstance.control = new FormControl('pizza-1'); + fixture.detectChanges(); + tick(); + const array = chips.toArray(); + + expect(array[1].selected).toBeTruthy('Expect pizza-1 chip to be selected'); + + dispatchKeyboardEvent(nativeChips[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(array[1].selected).toBeFalsy( + 'Expect chip to be not selected after toggle selected'); + })); + + it('should set the view value from the form', () => { + const chipListbox = fixture.componentInstance.chipListbox; + const array = chips.toArray(); + + expect(chipListbox.value).toBeFalsy('Expect chip listbox to have no initial value'); + + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + expect(array[1].selected).toBeTruthy('Expect chip to be selected'); + }); + + it('should update the form value when the view changes', fakeAsync(() => { + expect(fixture.componentInstance.control.value) + .toEqual(null, `Expected the control's value to be empty initially.`); + + dispatchKeyboardEvent(nativeChips[0], 'keydown', SPACE); + fixture.detectChanges(); + + tick(); + + expect(fixture.componentInstance.control.value) + .toEqual('steak-0', `Expected control's value to be set to the new option.`); + })); + + it('should clear the selection when a nonexistent option value is selected', () => { + const array = chips.toArray(); + + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + expect(array[1].selected) + .toBeTruthy(`Expected chip with the value to be selected.`); + + fixture.componentInstance.control.setValue('gibberish'); + + fixture.detectChanges(); + + expect(array[1].selected) + .toBeFalsy(`Expected chip with the old value not to be selected.`); + }); + + + it('should clear the selection when the control is reset', () => { + const array = chips.toArray(); + + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); + + fixture.componentInstance.control.reset(); + fixture.detectChanges(); + + expect(array[1].selected) + .toBeFalsy(`Expected chip with the old value not to be selected.`); + }); + + it('should set the control to touched when the chip listbox is touched', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + const nativeChipListbox = fixture.debugElement.query( + By.css('mat-chip-listbox')).nativeElement; + dispatchFakeEvent(nativeChipListbox, 'blur'); + tick(); + + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched.'); + })); + + it('should not set touched when a disabled chip listbox is touched', fakeAsync(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + const nativeChipListbox = fixture.debugElement.query( + By.css('mat-chip-listbox')).nativeElement; + dispatchFakeEvent(nativeChipListbox, 'blur'); + tick(); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + })); + + it('should set the control to dirty when the chip listbox\'s value changes in the DOM', + () => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + dispatchKeyboardEvent(nativeChips[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.dirty) + .toEqual(true, `Expected control to be dirty after value was changed by user.`); + }); + + it('should not set the control to dirty when the value changes programmatically', () => { + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to start out pristine.`); + + fixture.componentInstance.control.setValue('pizza-1'); + + expect(fixture.componentInstance.control.dirty) + .toEqual(false, `Expected control to stay pristine after programmatic change.`); + }); + + it('should be able to programmatically select a falsy option', () => { + fixture.destroy(); + TestBed.resetTestingModule(); + + const falsyFixture = createComponent(FalsyValueChipListbox); + falsyFixture.detectChanges(); + + falsyFixture.componentInstance.control.setValue([0]); + falsyFixture.detectChanges(); + falsyFixture.detectChanges(); + + expect(falsyFixture.componentInstance.chips.first.selected) + .toBe(true, 'Expected first option to be selected'); + }); + + it('should not focus the active chip when the value is set programmatically', () => { + const chipArray = fixture.componentInstance.chips.toArray(); + + spyOn(chipArray[4], 'focus').and.callThrough(); + + fixture.componentInstance.control.setValue('chips-4'); + fixture.detectChanges(); + + expect(chipArray[4].focus).not.toHaveBeenCalled(); + }); + }); + + describe('multiple selection', () => { + beforeEach(() => { + fixture = createComponent(MultiSelectionChipListbox); + fixture.detectChanges(); + + nativeChips = fixture.debugElement.queryAll(By.css('mat-chip-option')) + .map((chip) => chip.nativeElement); + chips = fixture.componentInstance.chips; + }); + + it('should take an initial view value with reactive forms', () => { + fixture.componentInstance.control = new FormControl(['pizza-1']); + fixture.detectChanges(); + + const array = chips.toArray(); + + expect(array[1].selected).toBeTruthy('Expect pizza-1 chip to be selected'); + + dispatchKeyboardEvent(nativeChips[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(array[1].selected).toBeFalsy( + 'Expect chip to be not selected after toggle selected'); + }); + + it('should set the view value from the form', () => { + const chipListbox = fixture.componentInstance.chipListbox; + const array = chips.toArray(); + + expect(chipListbox.value).toBeFalsy('Expect chip listbox to have no initial value'); + + fixture.componentInstance.control.setValue(['pizza-1']); + fixture.detectChanges(); + + expect(array[1].selected).toBeTruthy('Expect chip to be selected'); + }); + + it('should update the form value when the view changes', () => { + + expect(fixture.componentInstance.control.value) + .toEqual(null, `Expected the control's value to be empty initially.`); + + dispatchKeyboardEvent(nativeChips[0], 'keydown', SPACE); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.value) + .toEqual(['steak-0'], `Expected control's value to be set to the new option.`); + }); + + it('should clear the selection when a nonexistent option value is selected', () => { + const array = chips.toArray(); + + fixture.componentInstance.control.setValue(['pizza-1']); + fixture.detectChanges(); + + expect(array[1].selected) + .toBeTruthy(`Expected chip with the value to be selected.`); + + fixture.componentInstance.control.setValue(['gibberish']); + + fixture.detectChanges(); + + expect(array[1].selected) + .toBeFalsy(`Expected chip with the old value not to be selected.`); + }); + + it('should clear the selection when the control is reset', () => { + const array = chips.toArray(); + + fixture.componentInstance.control.setValue(['pizza-1']); + fixture.detectChanges(); + + fixture.componentInstance.control.reset(); + fixture.detectChanges(); + + expect(array[1].selected) + .toBeFalsy(`Expected chip with the old value not to be selected.`); + }); + }); + }); + }); + + function createComponent(component: Type, providers: Provider[] = []): + ComponentFixture { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + MatChipsModule, + ], + declarations: [component], + providers: [ + {provide: NgZone, useFactory: () => zone = new MockNgZone()}, + ...providers + ] + }).compileComponents(); + + return TestBed.createComponent(component); + } + + function setupStandardListbox(direction: Direction = 'ltr') { + dirChange = new Subject(); + fixture = createComponent(StandardChipListbox, [{ + provide: Directionality, useFactory: () => ({ + value: direction.toLowerCase(), + change: dirChange + }) + }]); + fixture.detectChanges(); + + chipListboxDebugElement = fixture.debugElement.query(By.directive(MatChipListbox)); + chipListboxNativeElement = chipListboxDebugElement.nativeElement; + chipListboxInstance = chipListboxDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + chips = chipListboxInstance._chips; + } +}); + +@Component({ + template: ` + + + {{name}} {{i + 1}} + + ` +}) +class StandardChipListbox { + name: string = 'Test'; + selectable: boolean = true; + chipSelect: (index?: number) => void = () => {}; + chipDeselect: (index?: number) => void = () => {}; + tabIndex: number = 0; + chips = [0, 1, 2, 3, 4]; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class BasicChipListbox { + 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(); + isRequired: boolean; + tabIndexOverride: number; + selectable: boolean; + + @ViewChild(MatChipListbox, {static: false}) chipListbox: MatChipListbox; + @ViewChildren(MatChipOption) chips: QueryList; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class MultiSelectionChipListbox { + 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(); + isRequired: boolean; + tabIndexOverride: number; + selectable: boolean; + + @ViewChild(MatChipListbox, {static: false}) chipListbox: MatChipListbox; + @ViewChildren(MatChipOption) chips: QueryList; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class FalsyValueChipListbox { + foods: any[] = [ + {value: 0, viewValue: 'Steak'}, + {value: 1, viewValue: 'Pizza'}, + ]; + control = new FormControl(); + @ViewChildren(MatChipOption) chips: QueryList; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class SelectedChipListbox { + foods: any[] = [ + {value: 0, viewValue: 'Steak', selected: true}, + {value: 1, viewValue: 'Pizza', selected: false}, + {value: 2, viewValue: 'Pasta', selected: true}, + ]; + @ViewChildren(MatChipOption) chips: QueryList; +} diff --git a/src/material-experimental/mdc-chips/chip-listbox.ts b/src/material-experimental/mdc-chips/chip-listbox.ts index 5744dc2c0b7f..6eda4095cf00 100644 --- a/src/material-experimental/mdc-chips/chip-listbox.ts +++ b/src/material-experimental/mdc-chips/chip-listbox.ts @@ -66,8 +66,8 @@ export const MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR: any = { inputs: ['tabIndex'], host: { 'class': 'mat-mdc-chip-set mat-mdc-chip-listbox mdc-chip-set', - 'role': 'listbox', - '[tabIndex]': 'tabIndex', + '[attr.role]': 'role', + '[tabIndex]': 'empty ? -1 : tabIndex', // TODO: replace this binding with use of AriaDescriber '[attr.aria-describedby]': '_ariaDescribedby || null', '[attr.aria-required]': 'required.toString()', @@ -127,6 +127,9 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont */ _onChange: (value: any) => void = () => {}; + /** The ARIA role applied to the chip listbox. */ + get role(): string | null { return this.empty ? null : 'listbox'; } + /** Whether the user should be allowed to select multiple chips. */ @Input() get multiple(): boolean { return this._multiple; } @@ -139,7 +142,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** The array of selected chips inside the chip listbox. */ get selected(): MatChipOption[] | MatChipOption { - const selectedChips = this._optionChips.toArray().filter(chip => chip.selected); + const selectedChips = this._chips.toArray().filter(chip => chip.selected); return this.multiple ? selectedChips : selectedChips[0]; } @@ -185,7 +188,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Combined stream of all of the child chips' selection change events. */ get chipSelectionChanges(): Observable { - return merge(...this._optionChips.map(chip => chip.selectionChange)); + return merge(...this._chips.map(chip => chip.selectionChange)); } /** Combined stream of all of the child chips' focus events. */ @@ -199,13 +202,13 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont } /** The value of the listbox, which is the combined value of the selected chips. */ - get value(): any { - if (Array.isArray(this.selected)) { - return this.selected.map(chip => chip.value); - } else { - return this.selected ? this.selected.value : null; - } + @Input() + get value(): any { return this._value; } + set value(value: any) { + this.writeValue(value); + this._value = value; } + protected _value: any; /** Event emitted when the selected chip listbox value has been changed by the user. */ @Output() readonly change: EventEmitter = @@ -216,7 +219,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont // indirect descendants if it's left as false. descendants: true }) - _optionChips: QueryList; + _chips: QueryList; constructor(protected _elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, @@ -231,16 +234,13 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont super.ngAfterContentInit(); this._initKeyManager(); - this._optionChips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { + this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { // Update listbox selectable/multiple properties on chips this._syncListboxProperties(); // Reset chips selected/deselected status this._initializeSelection(); - // Check to see if we need to update our tab index - this._updateTabIndex(); - // Check to see if we have a destroyed chip and need to refocus this._updateFocusForDestroyedChips(); }); @@ -291,6 +291,14 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont this._onTouched = fn; } + /** + * Implemented as part of ControlValueAccessor. + * @docs-private + */ + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + /** Selects all chips with value. */ _setSelectionByValue(value: any, isUserInput: boolean = true) { this._clearSelection(); @@ -312,7 +320,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Selects or deselects a chip by id. */ _setSelected(chipId: string, selected: boolean) { - const chip = this._optionChips.find(c => c.id === chipId); + const chip = this._chips.find(c => c.id === chipId); if (chip && chip.selected != selected) { chip.toggleSelected(true); } @@ -324,10 +332,13 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont return; } + if (!this.focused) { + this._keyManager.setActiveItem(-1); + } + // Wait to see if focus moves to an indivdual chip. setTimeout(() => { if (!this.focused) { - this._keyManager.setActiveItem(-1); this._propagateChanges(); this._markAsTouched(); } @@ -340,11 +351,13 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont * it back to the first chip, creating a focus trap, if it user tries to tab away. */ _allowFocusEscape() { + const previousTabIndex = this.tabIndex; + if (this.tabIndex !== -1) { this.tabIndex = -1; setTimeout(() => { - this.tabIndex = 0; + this.tabIndex = previousTabIndex; this._changeDetectorRef.markForCheck(); }); } @@ -354,9 +367,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont * Handles custom keyboard shortcuts, and passes other keyboard events to the keyboard manager. */ _keydown(event: KeyboardEvent) { - const target = event.target as HTMLElement; - - if (target && target.classList.contains('mdc-chip')) { + if (this._originatesFromChip(event)) { if (event.keyCode === HOME) { this._keyManager.setFirstItemActive(); event.preventDefault(); @@ -377,7 +388,14 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Emits change event to set the model value. */ private _propagateChanges(fallbackValue?: any): void { - let valueToEmit: any = this.value || fallbackValue; + let valueToEmit: any = null; + + if (Array.isArray(this.selected)) { + valueToEmit = this.selected.map(chip => chip.value); + } else { + valueToEmit = this.selected ? this.selected.value : fallbackValue; + } + this._value = valueToEmit; this.change.emit(new MatChipListboxChange(this, valueToEmit)); this._onChange(valueToEmit); this._changeDetectorRef.markForCheck(); @@ -387,10 +405,14 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont * Initializes the chip listbox selection state to reflect any chips that were preselected. */ private _initializeSelection() { - this._optionChips.forEach(chip => { - if (chip.selected) { - this._chipSetFoundation.select(chip.id); - } + setTimeout(() => { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + this._chips.forEach(chip => { + if (chip.selected) { + this._chipSetFoundation.select(chip.id); + } + }); }); } @@ -399,7 +421,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont * @param skip Chip that should not be deselected. */ private _clearSelection(skip?: MatChip): void { - this._optionChips.forEach(chip => { + this._chips.forEach(chip => { if (chip !== skip) { chip.deselect(); } @@ -412,7 +434,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont */ private _selectValue(value: any, isUserInput: boolean = true): MatChip | undefined { - const correspondingChip = this._optionChips.find(chip => { + const correspondingChip = this._chips.find(chip => { return chip.value != null && this._compareWith(chip.value, value); }); @@ -425,11 +447,11 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Syncs the chip-listbox selection state with the individual chips. */ private _syncListboxProperties() { - if (this._optionChips) { + if (this._chips) { // Defer setting the value in order to avoid the "Expression // has changed after it was checked" errors from Angular. Promise.resolve().then(() => { - this._optionChips.forEach(chip => { + this._chips.forEach(chip => { chip._chipListMultiple = this.multiple; chip.chipListSelectable = this._selectable; chip._changeDetectorRef.markForCheck(); @@ -446,7 +468,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Initializes the key manager to manage focus. */ private _initKeyManager() { - this._keyManager = new FocusKeyManager(this._optionChips) + this._keyManager = new FocusKeyManager(this._chips) .withWrap() .withVerticalOrientation() .withHorizontalOrientation(this._dir ? this._dir.value : 'ltr'); @@ -501,7 +523,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont /** Subscribes to chip focus events. */ private _listenToChipsFocus(): void { this._chipFocusSubscription = this.chipFocusChanges.subscribe((event: MatChipEvent) => { - let chipIndex: number = this._chips.toArray().indexOf(event.chip); + let chipIndex: number = this._chips.toArray().indexOf(event.chip as MatChipOption); if (this._isValidIndex(chipIndex)) { this._keyManager.updateActiveItemIndex(chipIndex); @@ -528,14 +550,6 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, Cont }); } - /** - * Check the tab index as you should not be allowed to focus an empty list. - */ - protected _updateTabIndex(): void { - // If we have 0 chips, we should not allow keyboard focus - this.tabIndex = this._chips.length === 0 ? -1 : 0; - } - /** * If the amount of chips changed, we need to update the * key manager state and focus the next closest chip. diff --git a/src/material-experimental/mdc-chips/chip-option.spec.ts b/src/material-experimental/mdc-chips/chip-option.spec.ts new file mode 100644 index 000000000000..9aecc265267d --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-option.spec.ts @@ -0,0 +1,306 @@ +import {Directionality} from '@angular/cdk/bidi'; +import {SPACE} from '@angular/cdk/keycodes'; +import {createKeyboardEvent, dispatchFakeEvent} from '@angular/cdk/testing'; +import {Component, DebugElement, ViewChild} from '@angular/core'; +import {async, ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {Subject} from 'rxjs'; +import { + MatChipEvent, + MatChipListbox, + MatChipOption, + MatChipSelectionChange, + MatChipsModule, +} from './index'; + + +describe('Option Chips', () => { + let fixture: ComponentFixture; + let chipDebugElement: DebugElement; + let chipNativeElement: HTMLElement; + let chipInstance: MatChipOption; + let globalRippleOptions: RippleGlobalOptions; + + let dir = 'ltr'; + + beforeEach(async(() => { + globalRippleOptions = {}; + TestBed.configureTestingModule({ + imports: [MatChipsModule], + declarations: [SingleChip], + providers: [ + {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, + {provide: Directionality, useFactory: () => ({ + value: dir, + change: new Subject() + })}, + ] + }); + + TestBed.compileComponents(); + })); + + describe('MatChipOption', () => { + let testComponent: SingleChip; + + beforeEach(() => { + fixture = TestBed.createComponent(SingleChip); + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MatChipOption)); + chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.injector.get(MatChipOption); + testComponent = fixture.debugElement.componentInstance; + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); + }); + + describe('basic behaviors', () => { + + it('adds the `mat-chip` class', () => { + expect(chipNativeElement.classList).toContain('mat-mdc-chip'); + }); + + it('emits focus only once for multiple clicks', () => { + let counter = 0; + chipInstance._onFocus.subscribe(() => { + counter ++ ; + }); + + chipNativeElement.focus(); + chipNativeElement.focus(); + fixture.detectChanges(); + + expect(counter).toBe(1); + }); + + it('emits destroy on destruction', () => { + spyOn(testComponent, 'chipDestroy').and.callThrough(); + + // Force a destroy callback + testComponent.shouldShow = false; + fixture.detectChanges(); + + expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); + }); + + it('allows color customization', () => { + expect(chipNativeElement.classList).toContain('mat-primary'); + + testComponent.color = 'warn'; + fixture.detectChanges(); + + expect(chipNativeElement.classList).not.toContain('mat-primary'); + expect(chipNativeElement.classList).toContain('mat-warn'); + }); + + it('allows selection', () => { + spyOn(testComponent, 'chipSelectionChange'); + expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-selected'); + + testComponent.selected = true; + fixture.detectChanges(); + + expect(chipNativeElement.classList).toContain('mat-mdc-chip-selected'); + expect(testComponent.chipSelectionChange) + .toHaveBeenCalledWith({source: chipInstance, isUserInput: false, selected: true}); + }); + + it('should not prevent the default click action', () => { + const event = dispatchFakeEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(false); + }); + + it('should prevent the default click action when the chip is disabled', () => { + chipInstance.disabled = true; + fixture.detectChanges(); + + const event = dispatchFakeEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should not dispatch `selectionChange` event when deselecting a non-selected chip', () => { + chipInstance.deselect(); + + const spy = jasmine.createSpy('selectionChange spy'); + const subscription = chipInstance.selectionChange.subscribe(spy); + + chipInstance.deselect(); + + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should not dispatch `selectionChange` event when selecting a selected chip', () => { + chipInstance.select(); + + const spy = jasmine.createSpy('selectionChange spy'); + const subscription = chipInstance.selectionChange.subscribe(spy); + + chipInstance.select(); + + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should not dispatch `selectionChange` event when selecting a selected chip via ' + + 'user interaction', () => { + chipInstance.select(); + + const spy = jasmine.createSpy('selectionChange spy'); + const subscription = chipInstance.selectionChange.subscribe(spy); + + chipInstance.selectViaInteraction(); + + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should not dispatch `selectionChange` through setter if the value did not change', () => { + chipInstance.selected = false; + + const spy = jasmine.createSpy('selectionChange spy'); + const subscription = chipInstance.selectionChange.subscribe(spy); + + chipInstance.selected = false; + + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + }); + + describe('keyboard behavior', () => { + + describe('when selectable is true', () => { + beforeEach(() => { + testComponent.selectable = true; + fixture.detectChanges(); + }); + + it('should selects/deselects the currently focused chip on SPACE', () => { + const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent; + const CHIP_SELECTED_EVENT: MatChipSelectionChange = { + source: chipInstance, + isUserInput: true, + selected: true + }; + + const CHIP_DESELECTED_EVENT: MatChipSelectionChange = { + source: chipInstance, + isUserInput: true, + selected: false + }; + + spyOn(testComponent, 'chipSelectionChange'); + + // Use the spacebar to select the chip + chipInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(chipInstance.selected).toBeTruthy(); + expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(1); + expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_SELECTED_EVENT); + + // Use the spacebar to deselect the chip + chipInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(chipInstance.selected).toBeFalsy(); + expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(2); + expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_DESELECTED_EVENT); + }); + + it('should have correct aria-selected in single selection mode', () => { + expect(chipNativeElement.hasAttribute('aria-selected')).toBe(false); + + testComponent.selected = true; + fixture.detectChanges(); + + expect(chipNativeElement.getAttribute('aria-selected')).toBe('true'); + }); + + it('should have the correct aria-selected in multi-selection mode', fakeAsync(() => { + testComponent.chipList.multiple = true; + flush(); + fixture.detectChanges(); + expect(chipNativeElement.getAttribute('aria-selected')).toBe('false'); + + testComponent.selected = true; + fixture.detectChanges(); + + expect(chipNativeElement.getAttribute('aria-selected')).toBe('true'); + })); + + }); + + describe('when selectable is false', () => { + beforeEach(() => { + testComponent.selectable = false; + fixture.detectChanges(); + }); + + it('SPACE ignores selection', () => { + const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent; + + spyOn(testComponent, 'chipSelectionChange'); + + // Use the spacebar to attempt to select the chip + chipInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(chipInstance.selected).toBeFalsy(); + expect(testComponent.chipSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not have the aria-selected attribute', () => { + expect(chipNativeElement.hasAttribute('aria-selected')).toBe(false); + }); + }); + + it('should update the aria-label for disabled chips', () => { + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); + + testComponent.disabled = true; + fixture.detectChanges(); + + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); + }); + }); + }); +}); + +@Component({ + template: ` + +
+ + {{name}} + +
+
` +}) +class SingleChip { + @ViewChild(MatChipListbox, {static: false}) chipList: MatChipListbox; + disabled: boolean = false; + name: string = 'Test'; + color: string = 'primary'; + selected: boolean = false; + selectable: boolean = true; + shouldShow: boolean = true; + + chipFocus: (event?: MatChipEvent) => void = () => {}; + chipDestroy: (event?: MatChipEvent) => void = () => {}; + chipSelectionChange: (event?: MatChipSelectionChange) => void = () => {}; +} diff --git a/src/material-experimental/mdc-chips/chip-option.ts b/src/material-experimental/mdc-chips/chip-option.ts index 45b2e9895d35..409b8fb29cff 100644 --- a/src/material-experimental/mdc-chips/chip-option.ts +++ b/src/material-experimental/mdc-chips/chip-option.ts @@ -16,6 +16,7 @@ import { Output, ViewEncapsulation } from '@angular/core'; +import {take} from 'rxjs/operators'; import {MatChip} from './chip'; @@ -182,10 +183,22 @@ export class MatChipOption extends MatChip { /** Resets the state of the chip when it loses focus. */ _blur(): void { - this._hasFocusInternal = false; - this._onBlur.next({chip: this}); + // When animations are enabled, Angular may end up removing the chip from the DOM a little + // earlier than usual, causing it to be blurred and throwing off the logic in the chip list + // that moves focus not the next item. To work around the issue, we defer marking the chip + // as not focused until the next time the zone stabilizes. + this._ngZone.onStable + .asObservable() + .pipe(take(1)) + .subscribe(() => { + this._ngZone.run(() => { + this._hasFocusInternal = false; + this._onBlur.next({chip: this}); + }); + }); } + /** Handles click events on the chip. */ _click(event: MouseEvent) { if (this.disabled) { diff --git a/src/material-experimental/mdc-chips/chip-remove.spec.ts b/src/material-experimental/mdc-chips/chip-remove.spec.ts new file mode 100644 index 000000000000..a75be569280a --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-remove.spec.ts @@ -0,0 +1,97 @@ +import {createFakeEvent} from '@angular/cdk/testing'; +import {Component, DebugElement} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatChip, MatChipsModule} from './index'; + +describe('Chip Remove', () => { + let fixture: ComponentFixture; + let testChip: TestChip; + let chipDebugElement: DebugElement; + let chipNativeElement: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatChipsModule], + declarations: [ + TestChip + ] + }); + + TestBed.compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(TestChip); + testChip = fixture.debugElement.componentInstance; + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MatChip)); + chipNativeElement = chipDebugElement.nativeElement; + })); + + describe('basic behavior', () => { + it('should apply the `mat-chip-remove` CSS class', () => { + let buttonElement = chipNativeElement.querySelector('button')!; + + expect(buttonElement.classList).toContain('mat-chip-remove'); + }); + + it('should start MDC exit animation on click', () => { + let buttonElement = chipNativeElement.querySelector('button')!; + + testChip.removable = true; + fixture.detectChanges(); + + buttonElement.click(); + fixture.detectChanges(); + + expect(chipNativeElement.classList.contains('mdc-chip--exit')).toBe(true); + }); + + it ('should emit (removed) event when exit animation is complete', () => { + let buttonElement = chipNativeElement.querySelector('button')!; + + testChip.removable = true; + fixture.detectChanges(); + + spyOn(testChip, 'didRemove'); + buttonElement.click(); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testChip.didRemove).toHaveBeenCalled(); + }); + + it('should not start MDC exit animation if parent chip is disabled', () => { + let buttonElement = chipNativeElement.querySelector('button')!; + + testChip.removable = true; + testChip.disabled = true; + fixture.detectChanges(); + + buttonElement.click(); + fixture.detectChanges(); + + expect(chipNativeElement.classList.contains('mdc-chip--exit')).toBe(false); + }); + }); +}); + +@Component({ + template: ` + + ` +}) +class TestChip { + removable: boolean; + disabled = false; + + didRemove() {} +} + diff --git a/src/material-experimental/mdc-chips/chip-row.spec.ts b/src/material-experimental/mdc-chips/chip-row.spec.ts new file mode 100644 index 000000000000..349be47888eb --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-row.spec.ts @@ -0,0 +1,242 @@ +import {Directionality} from '@angular/cdk/bidi'; +import {BACKSPACE, DELETE} from '@angular/cdk/keycodes'; +import {createKeyboardEvent, createFakeEvent, dispatchFakeEvent} from '@angular/cdk/testing'; +import {Component, DebugElement, ViewChild} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {Subject} from 'rxjs'; +import {MatChipEvent, MatChipGrid, MatChipRow, MatChipsModule} from './index'; + + +describe('Row Chips', () => { + let fixture: ComponentFixture; + let chipDebugElement: DebugElement; + let chipNativeElement: HTMLElement; + let chipInstance: MatChipRow; + let globalRippleOptions: RippleGlobalOptions; + + let dir = 'ltr'; + + beforeEach(async(() => { + globalRippleOptions = {}; + TestBed.configureTestingModule({ + imports: [MatChipsModule], + declarations: [SingleChip], + providers: [ + {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, + {provide: Directionality, useFactory: () => ({ + value: dir, + change: new Subject() + })}, + ] + }); + + TestBed.compileComponents(); + })); + + describe('MatChipRow', () => { + let testComponent: SingleChip; + + beforeEach(() => { + fixture = TestBed.createComponent(SingleChip); + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MatChipRow)); + chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.injector.get(MatChipRow); + testComponent = fixture.debugElement.componentInstance; + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); + }); + + describe('basic behaviors', () => { + + it('adds the `mat-mdc-chip` class', () => { + expect(chipNativeElement.classList).toContain('mat-mdc-chip'); + }); + + it('does not add the `mat-basic-chip` class', () => { + expect(chipNativeElement.classList).not.toContain('mat-basic-chip'); + }); + + it('emits destroy on destruction', () => { + spyOn(testComponent, 'chipDestroy').and.callThrough(); + + // Force a destroy callback + testComponent.shouldShow = false; + fixture.detectChanges(); + + expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); + }); + + it('allows color customization', () => { + expect(chipNativeElement.classList).toContain('mat-primary'); + + testComponent.color = 'warn'; + fixture.detectChanges(); + + expect(chipNativeElement.classList).not.toContain('mat-primary'); + expect(chipNativeElement.classList).toContain('mat-warn'); + }); + + it('allows removal', () => { + spyOn(testComponent, 'chipRemove'); + + chipInstance.remove(); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).toHaveBeenCalledWith({chip: chipInstance}); + }); + + it('should prevent the default click action', () => { + const event = dispatchFakeEvent(chipNativeElement, 'mousedown'); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(true); + }); + }); + + describe('keyboard behavior', () => { + describe('when removable is true', () => { + beforeEach(() => { + testComponent.removable = true; + fixture.detectChanges(); + }); + + it('DELETE emits the (removed) event', () => { + const DELETE_EVENT = createKeyboardEvent('keydown', DELETE) as KeyboardEvent; + + spyOn(testComponent, 'chipRemove'); + + chipInstance._keydown(DELETE_EVENT); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), + {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).toHaveBeenCalled(); + }); + + it('BACKSPACE emits the (removed) event', () => { + const BACKSPACE_EVENT = createKeyboardEvent('keydown', BACKSPACE) as KeyboardEvent; + + spyOn(testComponent, 'chipRemove'); + + chipInstance._keydown(BACKSPACE_EVENT); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), + {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).toHaveBeenCalled(); + }); + }); + + describe('when removable is false', () => { + beforeEach(() => { + testComponent.removable = false; + fixture.detectChanges(); + }); + + it('DELETE does not emit the (removed) event', () => { + const DELETE_EVENT = createKeyboardEvent('keydown', DELETE) as KeyboardEvent; + + spyOn(testComponent, 'chipRemove'); + + chipInstance._keydown(DELETE_EVENT); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), + {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).not.toHaveBeenCalled(); + }); + + it('BACKSPACE does not emit the (removed) event', () => { + const BACKSPACE_EVENT = createKeyboardEvent('keydown', BACKSPACE) as KeyboardEvent; + + spyOn(testComponent, 'chipRemove'); + + // Use the delete to remove the chip + chipInstance._keydown(BACKSPACE_EVENT); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), + {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).not.toHaveBeenCalled(); + }); + }); + + it('should update the aria-label for disabled chips', () => { + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); + + testComponent.disabled = true; + fixture.detectChanges(); + + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + describe('focus management', () => { + it('sends focus to first grid cell on click', () => { + dispatchFakeEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + + expect(document.activeElement!.classList.contains('mat-chip-row-focusable-text-content')); + }); + + it('emits focus only once for multiple focus() calls', () => { + let counter = 0; + chipInstance._onFocus.subscribe(() => { + counter ++ ; + }); + + chipInstance.focus(); + chipInstance.focus(); + fixture.detectChanges(); + + expect(counter).toBe(1); + }); + }); + }); + }); +}); + +@Component({ + template: ` + +
+ + {{name}} + + +
+
` +}) +class SingleChip { + @ViewChild(MatChipGrid, {static: false}) chipList: MatChipGrid; + disabled: boolean = false; + name: string = 'Test'; + color: string = 'primary'; + removable: boolean = true; + shouldShow: boolean = true; + + chipFocus: (event?: MatChipEvent) => void = () => {}; + chipDestroy: (event?: MatChipEvent) => void = () => {}; + chipRemove: (event?: MatChipEvent) => void = () => {}; +} diff --git a/src/material-experimental/mdc-chips/chip-row.ts b/src/material-experimental/mdc-chips/chip-row.ts index 73b81bb68d4c..fb2a1841acd8 100644 --- a/src/material-experimental/mdc-chips/chip-row.ts +++ b/src/material-experimental/mdc-chips/chip-row.ts @@ -40,7 +40,7 @@ import {GridKeyManagerRow, NAVIGATION_KEYS} from './grid-key-manager'; '[attr.disabled]': 'disabled || null', '[attr.aria-disabled]': 'disabled.toString()', '[tabIndex]': 'tabIndex', - '(click)': '_click($event)', + '(mousedown)': '_mousedown($event)', '(keydown)': '_keydown($event)', '(transitionend)': '_chipFoundation.handleTransitionEnd($event)', '(focusin)': '_focusin()', @@ -93,9 +93,11 @@ export class MatChipRow extends MatChip implements AfterContentInit, AfterViewIn return; } + if (!this._hasFocusInternal) { + this._onFocus.next({chip: this}); + } + this.chipContent.nativeElement.focus(); - this._hasFocusInternal = true; - this._onFocus.next({chip: this}); } /** @@ -119,13 +121,12 @@ export class MatChipRow extends MatChip implements AfterContentInit, AfterViewIn } /** Sends focus to the first gridcell when the user clicks anywhere inside the chip. */ - _click(event: MouseEvent) { - if (this.disabled) { - event.preventDefault(); - } else { + _mousedown(event: MouseEvent) { + if (!this.disabled) { this.focus(); - event.stopPropagation(); } + + event.preventDefault(); } /** Handles custom key presses. */ diff --git a/src/material-experimental/mdc-chips/chip-set.spec.ts b/src/material-experimental/mdc-chips/chip-set.spec.ts new file mode 100644 index 000000000000..48763e87194c --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-set.spec.ts @@ -0,0 +1,88 @@ +import {Component, DebugElement, QueryList} from '@angular/core'; +import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {MatChip, MatChipSet, MatChipsModule} from './index'; + + +describe('MatChipSet', () => { + let fixture: ComponentFixture; + let chipSetDebugElement: DebugElement; + let chipSetNativeElement: HTMLElement; + let chipSetInstance: MatChipSet; + let chips: QueryList; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatChipsModule], + declarations: [BasicChipSet], + }); + + TestBed.compileComponents(); + })); + + describe('BasicChipSet', () => { + describe('basic behaviors', () => { + beforeEach(() => { + fixture = TestBed.createComponent(BasicChipSet); + fixture.detectChanges(); + + chipSetDebugElement = fixture.debugElement.query(By.directive(MatChipSet)); + chipSetNativeElement = chipSetDebugElement.nativeElement; + chipSetInstance = chipSetDebugElement.componentInstance; + chips = chipSetInstance._chips; + }); + + it('should add the `mat-mdc-chip-set` class', () => { + expect(chipSetNativeElement.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); + + chipSetInstance.disabled = true; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + chipSetInstance.disabled = false; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(false); + }); + + it('should disable a chip that is added after the set became disabled', fakeAsync(() => { + expect(chips.toArray().every(chip => chip.disabled)).toBe(false); + + chipSetInstance.disabled = true; + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + + fixture.componentInstance.chips.push(5, 6); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(chips.toArray().every(chip => chip.disabled)).toBe(true); + })); + + it('should have role presentation', () => { + expect(chipSetNativeElement.getAttribute('role')).toBe('presentation'); + }); + }); + }); +}); + +@Component({ + template: ` + + + {{name}} {{i + 1}} + + + ` +}) +class BasicChipSet { + name: string = 'Test'; + chips = [0, 1, 2, 3, 4]; +} diff --git a/src/material-experimental/mdc-chips/chip-set.ts b/src/material-experimental/mdc-chips/chip-set.ts index 7cbceec71e10..97a9b533a5a8 100644 --- a/src/material-experimental/mdc-chips/chip-set.ts +++ b/src/material-experimental/mdc-chips/chip-set.ts @@ -57,7 +57,7 @@ const _MatChipSetMixinBase: HasTabIndexCtor & typeof MatChipSetBase = styleUrls: ['chips.css'], host: { 'class': 'mat-mdc-chip-set mdc-chip-set', - 'role': 'presentation', + '[attr.role]': 'role', // TODO: replace this binding with use of AriaDescriber '[attr.aria-describedby]': '_ariaDescribedby || null', '[id]': '_uid', @@ -70,6 +70,9 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit /** Subscription to remove changes in chips. */ private _chipRemoveSubscription: Subscription | null; + /** Subscription to destroyed events in chips. */ + private _chipDestroyedSubscription: Subscription | null; + /** Subscription to chip interactions. */ private _chipInteractionSubscription: Subscription | null; @@ -123,6 +126,9 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit /** Whether the chip list contains chips or not. */ get empty(): boolean { return this._chips.length === 0; } + /** The ARIA role applied to the chip set. */ + get role(): string | null { return this.empty ? null : 'presentation'; } + /** Whether any of the chips inside of this chip-set has focus. */ get focused(): boolean { return this._hasFocusedChip(); } @@ -131,6 +137,11 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit return merge(...this._chips.map(chip => chip.removed)); } + /** Combined stream of all of the child chips' remove events. */ + get chipDestroyedChanges(): Observable { + return merge(...this._chips.map(chip => chip.destroyed)); + } + /** Combined stream of all of the child chips' interaction events. */ get chipInteractionChanges(): Observable { return merge(...this._chips.map(chip => chip.interaction)); @@ -206,45 +217,39 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit /** Subscribes to events on the child chips. */ protected _subscribeToChipEvents() { this._listenToChipsRemove(); + this._listenToChipsDestroyed(); this._listenToChipsInteraction(); } /** Subscribes to chip removal events. */ private _listenToChipsRemove() { this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event: MatChipEvent) => { - this._handleChipRemove(event); + this._chipSetFoundation.handleChipRemoval(event.chip.id); + }); + } + + /** Subscribes to chip destroyed events. */ + private _listenToChipsDestroyed() { + this._chipDestroyedSubscription = this.chipDestroyedChanges.subscribe((event: MatChipEvent) => { + const chip = event.chip; + const chipIndex: number = this._chips.toArray().indexOf(event.chip); + + // In case the chip that will be removed is currently focused, we temporarily store + // the index in order to be able to determine an appropriate sibling chip that will + // receive focus. + if (this._isValidIndex(chipIndex) && chip._hasFocus) { + this._lastDestroyedChipIndex = chipIndex; + } }); } /** Subscribes to chip interaction events. */ private _listenToChipsInteraction() { this._chipInteractionSubscription = this.chipInteractionChanges.subscribe((id: string) => { - this._handleChipInteraction(id); + this._chipSetFoundation.handleChipInteraction(id); }); } - /** - * Called when one of the chips is about to be removed. - * If the removed chip has focus, stores its index so we can refocus. - */ - protected _handleChipRemove(event: MatChipEvent) { - this._chipSetFoundation.handleChipRemoval(event.chip.id); - const chip = event.chip; - const chipIndex: number = this._chips.toArray().indexOf(event.chip); - - // In case the chip that will be removed is currently focused, we temporarily store - // the index in order to be able to determine an appropriate sibling chip that will - // receive focus. - if (this._isValidIndex(chipIndex) && chip._hasFocus) { - this._lastDestroyedChipIndex = chipIndex; - } - } - - /** Notifies the chip set foundation when the user interacts with a chip. */ - protected _handleChipInteraction(id: string) { - this._chipSetFoundation.handleChipInteraction(id); - } - /** Unsubscribes from all chip events. */ protected _dropSubscriptions() { if (this._chipRemoveSubscription) { @@ -256,6 +261,11 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit this._chipInteractionSubscription.unsubscribe(); this._chipInteractionSubscription = null; } + + if (this._chipDestroyedSubscription) { + this._chipDestroyedSubscription.unsubscribe(); + this._chipDestroyedSubscription = null; + } } /** Dummy method for subclasses to override. Base chip set cannot be focused. */ @@ -270,5 +280,20 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit protected _isValidIndex(index: number): boolean { return index >= 0 && index < this._chips.length; } + + /** Checks whether an event comes from inside a chip element. */ + protected _originatesFromChip(event: Event): boolean { + let currentElement = event.target as HTMLElement | null; + + while (currentElement && currentElement !== this._elementRef.nativeElement) { + if (currentElement.classList.contains('mdc-chip')) { + return true; + } + + currentElement = currentElement.parentElement; + } + + return false; + } } diff --git a/src/material-experimental/mdc-chips/chip.spec.ts b/src/material-experimental/mdc-chips/chip.spec.ts new file mode 100644 index 000000000000..6bdffdbf276e --- /dev/null +++ b/src/material-experimental/mdc-chips/chip.spec.ts @@ -0,0 +1,171 @@ +import {Directionality} from '@angular/cdk/bidi'; +import {createFakeEvent} from '@angular/cdk/testing'; +import {Component, DebugElement, ViewChild} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {Subject} from 'rxjs'; +import {MatChip, MatChipEvent, MatChipSet, MatChipsModule} from './index'; + + +describe('Chips', () => { + let fixture: ComponentFixture; + let chipDebugElement: DebugElement; + let chipNativeElement: HTMLElement; + let chipInstance: MatChip; + let globalRippleOptions: RippleGlobalOptions; + + let dir = 'ltr'; + + beforeEach(async(() => { + globalRippleOptions = {}; + TestBed.configureTestingModule({ + imports: [MatChipsModule], + declarations: [BasicChip, SingleChip], + providers: [ + {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, + {provide: Directionality, useFactory: () => ({ + value: dir, + change: new Subject() + })}, + ] + }); + + TestBed.compileComponents(); + })); + + describe('MatBasicChip', () => { + + beforeEach(() => { + fixture = TestBed.createComponent(BasicChip); + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MatChip)); + chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.injector.get(MatChip); + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); + }); + + it('adds the `mat-mdc-basic-chip` class', () => { + expect(chipNativeElement.classList).toContain('mat-mdc-basic-chip'); + }); + }); + + describe('MatChip', () => { + let testComponent: SingleChip; + + beforeEach(() => { + fixture = TestBed.createComponent(SingleChip); + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MatChip)); + chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.injector.get(MatChip); + testComponent = fixture.debugElement.componentInstance; + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); + }); + + it('adds the `mat-chip` class', () => { + expect(chipNativeElement.classList).toContain('mat-mdc-chip'); + }); + + it('does not add the `mat-basic-chip` class', () => { + expect(chipNativeElement.classList).not.toContain('mat-mdc-basic-chip'); + }); + + it('emits destroy on destruction', () => { + spyOn(testComponent, 'chipDestroy').and.callThrough(); + + // Force a destroy callback + testComponent.shouldShow = false; + fixture.detectChanges(); + + expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); + }); + + it('allows color customization', () => { + expect(chipNativeElement.classList).toContain('mat-primary'); + + testComponent.color = 'warn'; + fixture.detectChanges(); + + expect(chipNativeElement.classList).not.toContain('mat-primary'); + expect(chipNativeElement.classList).toContain('mat-warn'); + }); + + it('allows removal', () => { + spyOn(testComponent, 'chipRemove'); + + chipInstance.remove(); + fixture.detectChanges(); + + const fakeEvent = Object.assign(createFakeEvent('transitionend'), {propertyName: 'width'}); + chipNativeElement.dispatchEvent(fakeEvent); + + expect(testComponent.chipRemove).toHaveBeenCalledWith({chip: chipInstance}); + }); + + it('should be able to disable ripples through ripple global options at runtime', () => { + expect(chipInstance.rippleDisabled).toBe(false, 'Expected chip ripples to be enabled.'); + + globalRippleOptions.disabled = true; + + expect(chipInstance.rippleDisabled).toBe(true, 'Expected chip ripples to be disabled.'); + }); + + it('should update the aria-label for disabled chips', () => { + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); + + testComponent.disabled = true; + fixture.detectChanges(); + + expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not be focusable', () => { + expect(chipNativeElement.getAttribute('tabindex')).toBeFalsy(); + }); + }); +}); + +@Component({ + template: ` + +
+ + {{name}} + +
+
` +}) +class SingleChip { + @ViewChild(MatChipSet, {static: false}) chipList: MatChipSet; + disabled: boolean = false; + name: string = 'Test'; + color: string = 'primary'; + removable: boolean = true; + shouldShow: boolean = true; + + chipFocus: (event?: MatChipEvent) => void = () => {}; + chipDestroy: (event?: MatChipEvent) => void = () => {}; + chipRemove: (event?: MatChipEvent) => void = () => {}; +} + +@Component({ + template: `{{name}}` +}) +class BasicChip { +} diff --git a/src/material-experimental/mdc-chips/chip.ts b/src/material-experimental/mdc-chips/chip.ts index 1a82fad60d82..73463c4068ab 100644 --- a/src/material-experimental/mdc-chips/chip.ts +++ b/src/material-experimental/mdc-chips/chip.ts @@ -8,6 +8,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import { AfterContentInit, AfterViewInit, @@ -18,9 +19,11 @@ import { Directive, ElementRef, EventEmitter, + Inject, Input, NgZone, OnDestroy, + Optional, Output, ViewEncapsulation } from '@angular/core'; @@ -29,12 +32,14 @@ import { CanColorCtor, CanDisableRipple, CanDisableRippleCtor, + MAT_RIPPLE_GLOBAL_OPTIONS, HasTabIndex, HasTabIndexCtor, mixinColor, mixinDisableRipple, mixinTabIndex, RippleConfig, + RippleGlobalOptions, RippleRenderer, RippleTarget, } from '@angular/material/core'; @@ -96,6 +101,9 @@ const _MatChipMixinBase: '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-with-trailing-icon]': 'trailingIcon || removeIcon', + '[class.mat-mdc-basic-chip]': '_isBasicChip()', + '[class.mat-mdc-standard-chip]': '!_isBasicChip()', + '[class._mat-animation-noopable]': '_animationsDisabled', '[id]': 'id', '[attr.disabled]': 'disabled || null', '[attr.aria-disabled]': 'disabled.toString()', @@ -117,6 +125,9 @@ export class MatChip extends _MatChipMixinBase implements AfterContentInit, Afte /** Whether the chip has focus. */ protected _hasFocusInternal = false; + /** Whether animations for the chip are enabled. */ + _animationsDisabled: boolean; + get _hasFocus() { return this._hasFocusInternal; } @@ -194,22 +205,19 @@ export class MatChip extends _MatChipMixinBase implements AfterContentInit, Afte private _rippleRenderer: RippleRenderer; /** - * Implemented as part of RippleTarget. Configures ripple animation to match MDC Ripple. + * Ripple configuration for ripples that are launched on pointer down. + * Implemented as part of RippleTarget. * @docs-private */ - rippleConfig: RippleConfig = { - animation: { - enterDuration: 225 /* MDCRippleFoundation.numbers.DEACTIVATION_TIMEOUT_MS */, - exitDuration: 150 /* MDCRippleFoundation.numbers.FG_DEACTIVATION_MS */, - } - }; + rippleConfig: RippleConfig & RippleGlobalOptions; /** * Implemented as part of RippleTarget. Whether ripples are disabled on interaction. * @docs-private */ get rippleDisabled(): boolean { - return this.disabled || this.disableRipple || this._isBasicChip(); + return this.disabled || this.disableRipple || !!this.rippleConfig.disabled || + this._isBasicChip(); } /** The chip's leading icon. */ @@ -258,9 +266,14 @@ export class MatChip extends _MatChipMixinBase implements AfterContentInit, Afte public _changeDetectorRef: ChangeDetectorRef, readonly _elementRef: ElementRef, private _platform: Platform, - private _ngZone: NgZone) { + protected _ngZone: NgZone, + @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) + private _globalRippleOptions: RippleGlobalOptions | null, + // @breaking-change 8.0.0 `animationMode` parameter to become required. + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { super(_elementRef); this._chipFoundation = new MDCChipFoundation(this._chipAdapter); + this._animationsDisabled = animationMode === 'NoopAnimations'; } ngAfterContentInit() { @@ -332,6 +345,14 @@ export class MatChip extends _MatChipMixinBase implements AfterContentInit, Afte /** Initializes the ripple renderer. */ private _initRipple() { + this.rippleConfig = this._globalRippleOptions || {}; + + // Configure ripple animation to match MDC Ripple. + this.rippleConfig.animation = { + enterDuration: 225 /*MDCRippleFoundation.numbers.DEACTIVATION_TIMEOUT_MS */, + exitDuration: 150 /* MDCRippleFoundation.numbers.FG_DEACTIVATION_MS */, + }; + this._rippleRenderer = new RippleRenderer(this, this._ngZone, this._elementRef, this._platform); this._rippleRenderer.setupTriggerEvents(this._elementRef.nativeElement); diff --git a/src/material-experimental/mdc-chips/chips.e2e.spec.ts b/src/material-experimental/mdc-chips/chips.e2e.spec.ts deleted file mode 100644 index 5a013f4f5e68..000000000000 --- a/src/material-experimental/mdc-chips/chips.e2e.spec.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: copy tests from existing mat-chip-list, update as necessary to fix. diff --git a/src/material-experimental/mdc-chips/chips.scss b/src/material-experimental/mdc-chips/chips.scss index 24f53f5632ec..9e3521ab0dd4 100644 --- a/src/material-experimental/mdc-chips/chips.scss +++ b/src/material-experimental/mdc-chips/chips.scss @@ -1,8 +1,10 @@ @import '@material/chips/mixins'; +@import '../../material/core/style/noop-animation'; @import '../mdc-helpers/mdc-helpers'; @include mdc-chip-without-ripple($query: $mat-base-styles-query); @include mdc-chip-set-core-styles($query: $mat-base-styles-query); +@include _noop-animation; // The MDC chip styles related to hover and focus states are intertwined with the MDC ripple styles. diff --git a/src/material-experimental/mdc-chips/chips.spec.ts b/src/material-experimental/mdc-chips/chips.spec.ts deleted file mode 100644 index 5a013f4f5e68..000000000000 --- a/src/material-experimental/mdc-chips/chips.spec.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: copy tests from existing mat-chip-list, update as necessary to fix.