From 38596a62cc85c49a6d9f7a4b4cb1d99218137805 Mon Sep 17 00:00:00 2001 From: Vanessa Schmitt Date: Thu, 27 Jun 2019 15:31:45 -0700 Subject: [PATCH] test(material-experimental/chips) Port unit tests for mdc chips Ported existing unit tests as follows: - Copied chip-remove.spec. - Copied chip-input.spec and changed its test component template to use grid/row. - Copied chip-list.spec basic tests to chip-set.spec. - Copied chip-list.spec to chip-listbox.spec and removed form-field integration, mat-chip-remove integration, and related keyboard shortcuts. - Copied chip-list.spec to chip-grid.spec and removed the selection behavior. Updated keyboard tests to reflect that it is using GridFocusKeyManager, which has both an active row and an active column. Updated some of the form tests where the value was being changed via selection to change the value in a different way, like by adding a chip. - Copied chip.spec basic tests to chip.spec. - Copied chip.spec to chip-option.spec and removed logic related to user removal of the chip. - Copied chip.spec to chip-row.spec and removed selection logic. Also updated component code to fix bugs/missing features that were caught by the unit tests: - Added missing ControlValueAccessor method setDisabledState to mat-chip-listbox and mat-chip-grid - Stopped overwriting user-defined tab index to mat-chip-listbox and mat-chip-grid. - Removed role from empty mat-chip-listbox and mat-chip-grid. - Added a new subscription in mat-chip-set to chip destroyed events, and moved the logic that updates the lastDestroyedChipIndex out of the removed subscription into the new destroyed subscription. - Replaced _optionChip and _rowChips ViewChildren in mat-chip-listbox and mat-chip-grid with just overriding _chips, to fix bugs where the _chips.changes subscription was trying to do things with _optionChips/_rowChips but those QueryLists were still out of date. - Added placeholder setter to mat-chip-grid. - Added value setter to mat-chip-listbox. - Added injected animationMode to mat-chip and its subcomponents. - Updated rippleConfig to be injected into mat-chip and its subcomponents. - Switched click handler to mousedown handler in mat-chip-row. - Added mat-chip-remove class to remove icon. --- .../mdc-chips/BUILD.bazel | 38 +- .../mdc-chips/chip-grid.spec.ts | 1098 +++++++++++++++++ .../mdc-chips/chip-grid.ts | 68 +- .../mdc-chips/chip-icons.ts | 2 +- .../mdc-chips/chip-input.spec.ts | 264 ++++ .../mdc-chips/chip-input.ts | 1 - .../mdc-chips/chip-listbox.spec.ts | 900 ++++++++++++++ .../mdc-chips/chip-listbox.ts | 94 +- .../mdc-chips/chip-option.spec.ts | 306 +++++ .../mdc-chips/chip-option.ts | 17 +- .../mdc-chips/chip-remove.spec.ts | 97 ++ .../mdc-chips/chip-row.spec.ts | 242 ++++ .../mdc-chips/chip-row.ts | 17 +- .../mdc-chips/chip-set.spec.ts | 88 ++ .../mdc-chips/chip-set.ts | 75 +- .../mdc-chips/chip.spec.ts | 171 +++ src/material-experimental/mdc-chips/chip.ts | 39 +- .../mdc-chips/chips.e2e.spec.ts | 1 - .../mdc-chips/chips.scss | 2 + .../mdc-chips/chips.spec.ts | 1 - 20 files changed, 3386 insertions(+), 135 deletions(-) create mode 100644 src/material-experimental/mdc-chips/chip-grid.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-input.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-listbox.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-option.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-remove.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-row.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip-set.spec.ts create mode 100644 src/material-experimental/mdc-chips/chip.spec.ts delete mode 100644 src/material-experimental/mdc-chips/chips.e2e.spec.ts delete mode 100644 src/material-experimental/mdc-chips/chips.spec.ts diff --git a/src/material-experimental/mdc-chips/BUILD.bazel b/src/material-experimental/mdc-chips/BUILD.bazel index f669fb35856a..c6af183692d7 100644 --- a/src/material-experimental/mdc-chips/BUILD.bazel +++ b/src/material-experimental/mdc-chips/BUILD.bazel @@ -1,8 +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") -load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") ng_module( name = "mdc-chips", @@ -15,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", @@ -45,18 +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", ], ) -e2e_test_suite( - name = "e2e_tests", +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.