From d262ff7df6d03e3e8be0cddcbb10bff2753ebea3 Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Mon, 21 Aug 2017 13:50:08 -0700 Subject: [PATCH 1/6] feat(chip-list): Implement FormFieldControl BREAKING CHANGES: - Deprecated: @Output() select, and @Output() deselect - Added @Output() onSelectionChange = EventEmitter --- src/demo-app/chips/chips-demo.html | 63 +++- src/demo-app/chips/chips-demo.scss | 4 + src/demo-app/chips/chips-demo.ts | 18 + src/lib/chips/chip-input.spec.ts | 3 +- src/lib/chips/chip-input.ts | 53 ++- src/lib/chips/chip-list.spec.ts | 561 ++++++++++++++++++++++++++++- src/lib/chips/chip-list.ts | 541 +++++++++++++++++++++++----- src/lib/chips/chip.spec.ts | 29 +- src/lib/chips/chip.ts | 103 ++++-- src/lib/chips/chips.md | 29 +- src/lib/chips/chips.scss | 15 +- src/lib/input/input.ts | 38 +- 12 files changed, 1255 insertions(+), 202 deletions(-) diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index b55c55954aea..3ada94d8eee0 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -45,20 +45,22 @@

Form Field

<md-form-field>.

- - - - + Set tabIndex to {{tabIndex === 0 ? -1 : 0}} + + + + {{person.name}} cancel + - @@ -68,21 +70,37 @@

Form Field

With mdChipInput the input work with chip-list

- - - {{person.name}} - cancel - - - + + {{person.name}} + cancel + +
+
+

+ Chips list without input +

+ + + + + + {{person.name}} + cancel + + + + +

The example above has overridden the [separatorKeys] input to allow for ENTER, COMMA and SEMI COLON keys. @@ -108,6 +126,17 @@

Stacked Chips

{{aColor.name}} + +

NgModel with chip list

+ + + {{aColor.name}} + cancel + + + + The selected colors are {{color}}. diff --git a/src/demo-app/chips/chips-demo.scss b/src/demo-app/chips/chips-demo.scss index 5fdb999211a1..5928eef7375b 100644 --- a/src/demo-app/chips/chips-demo.scss +++ b/src/demo-app/chips/chips-demo.scss @@ -25,3 +25,7 @@ width: 150px; } } + +.has-chip-list { + width: 100%; +} diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts index ce46def82be2..8f63a4f3efd9 100644 --- a/src/demo-app/chips/chips-demo.ts +++ b/src/demo-app/chips/chips-demo.ts @@ -20,6 +20,7 @@ export interface DemoColor { styleUrls: ['chips-demo.css'] }) export class ChipsDemo { + tabIndex: number = 0; visible: boolean = true; color: string = ''; selectable: boolean = true; @@ -30,6 +31,8 @@ export class ChipsDemo { // Enter, comma, semi-colon separatorKeysCodes = [ENTER, COMMA, 186]; + selectedPeople = null; + people: Person[] = [ { name: 'Kara' }, { name: 'Jeremy' }, @@ -73,7 +76,22 @@ export class ChipsDemo { } } + removeColor(color: DemoColor) { + let index = this.availableColors.indexOf(color); + + if (index >= 0) { + this.availableColors.splice(index, 1); + } + + index = this.selectedColors.indexOf(color.name); + + if (index >= 0) { + this.selectedColors.splice(index, 1); + } + } + toggleVisible(): void { this.visible = false; } + selectedColors: any[] = ['Primary', 'Warn']; } diff --git a/src/lib/chips/chip-input.spec.ts b/src/lib/chips/chip-input.spec.ts index 827edb24ffd1..b50edcd4dbb9 100644 --- a/src/lib/chips/chip-input.spec.ts +++ b/src/lib/chips/chip-input.spec.ts @@ -1,6 +1,7 @@ import {async, TestBed, ComponentFixture} from '@angular/core/testing'; import {MdChipsModule} from './index'; import {Component, DebugElement} from '@angular/core'; +import {PlatformModule} from '../core/platform/index'; import {MdChipInput, MdChipInputEvent} from './chip-input'; import {By} from '@angular/platform-browser'; import {Directionality} from '@angular/material/core'; @@ -21,7 +22,7 @@ describe('MdChipInput', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdChipsModule], + imports: [MdChipsModule, PlatformModule], declarations: [TestChipInput], providers: [{ provide: Directionality, useFactory: () => { diff --git a/src/lib/chips/chip-input.ts b/src/lib/chips/chip-input.ts index ed7ac8470e99..7a8902329ef4 100644 --- a/src/lib/chips/chip-input.ts +++ b/src/lib/chips/chip-input.ts @@ -6,7 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Output, EventEmitter, ElementRef, Input} from '@angular/core'; +import { + Directive, + ElementRef, + Output, + EventEmitter, + Input, + Optional, + Renderer2, + Self, +} from '@angular/core'; +import {FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import {Platform} from '@angular/cdk/platform'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {ENTER} from '@angular/material/core'; import {MdChipList} from './chip-list'; @@ -16,16 +27,21 @@ export interface MdChipInputEvent { value: string; } +/** + * Directive that adds chip-specific behaviors to an input element inside . + * May be placed inside or outside of an . + */ @Directive({ selector: 'input[mdChipInputFor], input[matChipInputFor]', host: { - 'class': 'mat-chip-input', + 'class': 'mat-chip-input mat-input-element', '(keydown)': '_keydown($event)', - '(blur)': '_blur()' + '(blur)': '_blur()', + '(focus)': '_focus()', } }) export class MdChipInput { - + focused: boolean = false; _chipList: MdChipList; /** Register input for chip list */ @@ -33,7 +49,7 @@ export class MdChipInput { set chipList(value: MdChipList) { if (value) { this._chipList = value; - this._chipList.registerInput(this._inputElement); + this._chipList.registerInput(this); } } @@ -68,10 +84,22 @@ export class MdChipInput { get matSeparatorKeyCodes() { return this.separatorKeyCodes; } set matSeparatorKeyCodes(v: number[]) { this.separatorKeyCodes = v; } + @Input() placeholder: string = ''; + + get empty(): boolean { + let value: string | null = this._elementRef.nativeElement.value; + return value == null || value === ''; + } + /** The native input element to which this directive is attached. */ protected _inputElement: HTMLInputElement; - constructor(protected _elementRef: ElementRef) { + constructor(protected _elementRef: ElementRef, + protected _renderer: Renderer2, + protected _platform: Platform, + @Optional() @Self() public ngControl: NgControl, + @Optional() protected _parentForm: NgForm, + @Optional() protected _parentFormGroup: FormGroupDirective) { this._inputElement = this._elementRef.nativeElement as HTMLInputElement; } @@ -85,6 +113,17 @@ export class MdChipInput { if (this.addOnBlur) { this._emitChipEnd(); } + this.focused = false; + // Blur the chip list if it is not focused + if (!this._chipList.focused) { + this._chipList._blur(); + } + this._chipList.stateChanges.next(); + } + + _focus() { + this.focused = true; + this._chipList.stateChanges.next(); } /** Checks to see if the (chipEnd) event needs to be emitted. */ @@ -100,4 +139,6 @@ export class MdChipInput { } } } + + focus() { this._elementRef.nativeElement.focus(); } } diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index 1a080fb4f708..3832fb40380f 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -1,13 +1,21 @@ +<<<<<<< HEAD import {FocusKeyManager} from '@angular/cdk/a11y'; -import {createKeyboardEvent} from '@angular/cdk/testing'; -import {Component, DebugElement, QueryList} from '@angular/core'; +import {createKeyboardEvent, dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import { + FormControl, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { BACKSPACE, DELETE, Directionality, + ENTER, LEFT_ARROW, RIGHT_ARROW, + SPACE, TAB, } from '@angular/material/core'; import {MdFormFieldModule} from '@angular/material/form-field'; @@ -16,6 +24,7 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MdInputModule} from '../input/index'; import {MdChip} from './chip'; +import {MdChipInputEvent} from './chip-input'; import {MdChipList, MdChipsModule} from './index'; @@ -31,9 +40,20 @@ describe('MdChipList', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdChipsModule, MdFormFieldModule, MdInputModule, NoopAnimationsModule], + imports: [ + FormsModule, + ReactiveFormsModule, + MdChipsModule, + MdFormFieldModule, + MdInputModule, + NoopAnimationsModule + ], declarations: [ - StandardChipList, FormFieldChipList + StandardChipList, + FormFieldChipList, + BasicChipList, + InputChipList, + FalsyValueChipList, ], providers: [{ provide: Directionality, useFactory: () => { @@ -311,6 +331,431 @@ describe('MdChipList', () => { }); + describe('selection logic', () => { + let formField: HTMLElement; + let nativeChips: HTMLElement[]; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicChipList); + fixture.detectChanges(); + + formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; + nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) + .map((chip) => chip.nativeElement); + + + chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList)); + chipListInstance = chipListDebugElement.componentInstance; + chips = chipListInstance.chips; + + }); + + it('should not float placeholder if no chip is selected', () => { + expect(formField.classList.contains('mat-form-field-should-float')) + .toBe(false, 'placeholder should not be floating'); + }); + + it('should remove selection if chip has been removed', async(() => { + const instanceChips = fixture.componentInstance.chips; + const chipList = fixture.componentInstance.chipList; + const firstChip = nativeChips[0]; + dispatchKeyboardEvent(firstChip, 'keydown', SPACE); + fixture.detectChanges(); + + expect(instanceChips.first.selected).toBe(true, 'Expected first option to be selected.'); + expect(chipList.selected.length).toBe(1, 'Expect one option to be selected'); + expect(chipList.selected[0]).toBe(chips.first, 'Expected first option to be selected.'); + + fixture.componentInstance.foods = []; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(chipList.selected.length) + .toBe(0, '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('md-chip')) + .map((chip) => chip.nativeElement); + const lastChip = nativeChips[8]; + dispatchKeyboardEvent(lastChip, 'keydown', SPACE); + fixture.detectChanges(); + + expect(fixture.componentInstance.chipList.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.chipList.value) + .toBeUndefined('Expect value to be undefined'); + expect(array[2].selected).toBeFalsy('Expect disabled chip not selected'); + expect(fixture.componentInstance.chipList.selected.length) + .toBe(0, 'Expect no selected chips'); + }); + + }); + + describe('forms integration', () => { + let formField: HTMLElement; + let nativeChips: HTMLElement[]; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicChipList); + fixture.detectChanges(); + + formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; + nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) + .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 chipList = fixture.componentInstance.chipList; + const array = chips.toArray(); + + expect(chipList.value).toBeFalsy('Expect chip list 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.`); + }); + + it('should set the control to touched when the chip list is touched', () => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + dispatchFakeEvent(nativeChipList, 'blur'); + + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched.'); + }); + + it('should not set touched when a disabled chip list is touched', () => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + dispatchFakeEvent(nativeChipList, 'blur'); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + }); + + it('should set the control to dirty when the chip list\'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 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 be able to programmatically select a falsy option', () => { + fixture.destroy(); + + const falsyFixture = TestBed.createComponent(FalsyValueChipList); + 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'); + }); + }); + + describe('chip list with chip input', () => { + let formField: HTMLElement; + let nativeChips: HTMLElement[]; + + beforeEach(() => { + fixture = TestBed.createComponent(InputChipList); + fixture.detectChanges(); + + formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; + nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) + .map((chip) => chip.nativeElement); + }); + + it('should take an initial view value with reactive forms', () => { + fixture.componentInstance.control = new FormControl(['pizza-1']); + fixture.detectChanges(); + + const array = fixture.componentInstance.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 array = fixture.componentInstance.chips.toArray(); + + expect(array[1].selected).toBeFalsy('Expect chip to not be selected'); + + 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 = fixture.componentInstance.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 = fixture.componentInstance.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 list is touched', async(() => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + const nativeInput = fixture.debugElement.query(By.css('.mat-chip-input')).nativeElement; + + dispatchFakeEvent(nativeChipList, 'blur'); + + fixture.whenStable().then(() => { + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched.'); + }); + })); + + it('should not set touched when a disabled chip list is touched', () => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); + + fixture.componentInstance.control.disable(); + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + dispatchFakeEvent(nativeChipList, 'blur'); + + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + }); + + it('should set the control to dirty when the chip list\'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 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.`); + }); + + describe('keyboard behavior', () => { + beforeEach(() => { + fixture = TestBed.createComponent(InputChipList); + fixture.detectChanges(); + chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList)); + chipListInstance = chipListDebugElement.componentInstance; + chips = chipListInstance.chips; + manager = fixture.componentInstance.chipList._keyManager; + }); + + describe('when the input has focus', () => { + + it('should 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.activeItemIndex).toBe(-1); + + // Press the DELETE key + chipListInstance._keydown(DELETE_EVENT); + fixture.detectChanges(); + + // It focuses the last chip + expect(manager.activeItemIndex).toEqual(chips.length - 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.activeItemIndex).toBe(-1); + + // Press the BACKSPACE key + chipListInstance._keydown(BACKSPACE_EVENT); + fixture.detectChanges(); + + // It focuses the last chip + expect(manager.activeItemIndex).toEqual(chips.length - 1); + }); + + }); + }); + }); + function setupStandardList() { fixture = TestBed.createComponent(StandardChipList); fixture.detectChanges(); @@ -370,3 +815,111 @@ class StandardChipList { }) class FormFieldChipList { } + + +@Component({ + selector: 'basic-chip-list', + template: ` + + + + {{ food.viewValue }} + + + + ` +}) +class BasicChipList { + 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(MdChipList) chipList: MdChipList; + @ViewChildren(MdChip) chips: QueryList; +} + +@Component({ + selector: 'input-chip-list', + template: ` + + + + {{ food.viewValue }} + + + /> + + ` +}) +class InputChipList { + 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: MdChipInputEvent): 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 = ''; + } + } + + @ViewChild(MdChipList) chipList: MdChipList; + @ViewChildren(MdChip) chips: QueryList; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class FalsyValueChipList { + foods: any[] = [ + { value: 0, viewValue: 'Steak' }, + { value: 1, viewValue: 'Pizza' }, + ]; + control = new FormControl(); + @ViewChildren(MdChip) chips: QueryList; +} diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index e2587427028e..443122120350 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -8,24 +8,47 @@ import { AfterContentInit, + ChangeDetectorRef, ChangeDetectionStrategy, Component, ContentChildren, + ElementRef, + EventEmitter, Input, - QueryList, - ViewEncapsulation, OnDestroy, + OnInit, Optional, - ElementRef, + Output, + QueryList, Renderer2, + Self, + ViewEncapsulation, } from '@angular/core'; +import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; -import {MdChip} from './chip'; import {FocusKeyManager} from '@angular/cdk/a11y'; import {BACKSPACE, DELETE, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '@angular/material/core'; import {Directionality} from '@angular/cdk/bidi'; -import {Subscription} from 'rxjs/Subscription'; +import {SelectionModel} from '@angular/cdk/collections'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {Subject} from 'rxjs/Subject'; +import {startWith} from '@angular/cdk/rxjs'; +import {Observable} from 'rxjs/Observable'; +import {merge} from 'rxjs/observable/merge'; +import {Subscription} from 'rxjs/Subscription'; + +import {MdChip, MdChipEvent, MdChipSelectionChange} from './chip'; +import {MdChipInput} from './chip-input'; +import {MdFormFieldControl} from '../form-field/form-field-control'; + +// Increasing integer for generating unique ids for chip-list components. +let nextUniqueId = 0; + +/** Change event object that is emitted when the chip list value has changed. */ +export class MdChipListChange { + constructor(public source: MdChipList, public value: any) { } +} + /** * A material design chips component (named ChipList for it's similarity to the List component). @@ -37,21 +60,34 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion'; exportAs: 'mdChipList', host: { '[attr.tabindex]': '_tabIndex', + '[attr.aria-describedby]': '_ariaDescribedby || null', + '[attr.aria-required]': 'required.toString()', + '[attr.aria-disabled]': 'disabled.toString()', + '[attr.aria-invalid]': 'errorState', + '[attr.aria-multiselectable]': 'true', + '[class.mat-chip-list-disabled]': 'disabled', + '[class.mat-chip-list-invalid]': 'errorState', + '[class.mat-chip-list-required]': 'required', 'role': 'listbox', '[attr.aria-orientation]': 'ariaOrientation', 'class': 'mat-chip-list', - '(focus)': 'focus()', + '(blur)': '_blur()', '(keydown)': '_keydown($event)' }, - queries: { - chips: new ContentChildren(MdChip) - }, + providers: [{provide: MdFormFieldControl, useExisting: MdChipList}], styleUrls: ['chips.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) -export class MdChipList implements AfterContentInit, OnDestroy { +export class MdChipList implements MdFormFieldControl, ControlValueAccessor, + AfterContentInit, OnInit, OnDestroy { + + /** + * Stream that emits whenever the state of the input changes such that the wrapping `MdFormField` + * needs to run change detection. + */ + stateChanges = new Subject(); /** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */ protected _lastDestroyedIndex: number|null = null; @@ -62,10 +98,43 @@ export class MdChipList implements AfterContentInit, OnDestroy { /** Subscription to tabbing out from the chip list. */ private _tabOutSubscription = Subscription.EMPTY; + /** Subscription to changes in the chip list. */ + private _changeSubscription: Subscription; + + /** Subscription to focus changes in the chips. */ + private _chipFocusSubscription: Subscription|null; + + /** Subscription to selection changes in chips. */ + private _chipSelectionSubscription: Subscription|null; + + /** Subscription to remove changes in chips. */ + private _chipRemoveSubscription: Subscription|null; + /** Whether or not the chip is selectable. */ protected _selectable: boolean = true; - protected _inputElement: HTMLInputElement; + /** The chip input to add more chips */ + protected _chipInput: MdChipInput; + + /** The aria-describedby attribute on the chip list for improved a11y. */ + protected _ariaDescribedby: string; + + /** Id of the chip list */ + protected _id: string; + + /** Uid of the chip list */ + protected _uid: string = `md-chip-list-${nextUniqueId++}`; + + /** Whether this is required */ + protected _required: boolean = false; + + /** Whether this is disabled */ + protected _disabled: boolean = false; + + protected _value: any; + + /** Placeholder for the chip list. Alternatively, placeholder can be set on MdChipInput */ + protected _placeholder: string; /** Tab index for the chip list. */ _tabIndex = 0; @@ -79,17 +148,157 @@ export class MdChipList implements AfterContentInit, OnDestroy { /** The FocusKeyManager which handles focus. */ _keyManager: FocusKeyManager; - /** The chip components contained within this chip list. */ - chips: QueryList; + /** Function when touched */ + _onTouched = () => {}; + + /** Function when changed */ + _onChange: (value: any) => void = () => {}; + + _selectionModel: SelectionModel; + + /** Comparison function to specify which option is displayed. Defaults to object equality. */ + private _compareWith = (o1: any, o2: any) => o1 === o2; + + /** The array of selected chips inside chip list. */ + get selected(): MdChip[] { + return this._selectionModel.selected; + } + + /** + * A function to compare the option values with the selected values. The first argument + * is a value from an option. The second is a value from the selection. A boolean + * should be returned. + */ + @Input() + get compareWith() { return this._compareWith; } + set compareWith(fn: (o1: any, o2: any) => boolean) { + this._compareWith = fn; + if (this._selectionModel) { + // A different comparator means the selection could change. + this._initializeSelection(); + } + } + + /** Required for FormFieldControl */ + @Input() + get value() { return this._value; } + set value(newValue: any) { + this.writeValue(newValue); + this._value = newValue; + } + + /** Required for FormFieldControl. The ID of the chip list */ + @Input() + set id(value: string) { + this._id = value; + this.stateChanges.next(); + } + get id() { return this._id || this._uid; } + + /** Required for FormFieldControl. Whether the chip list is required. */ + @Input() + set required(value: any) { + this._required = coerceBooleanProperty(value); + this.stateChanges.next(); + } + get required() { + return this._required; + } + + /** For FormFieldControl. Use chip input's placholder if there's a chip input */ + @Input() + set placeholder(value: string) { + this._placeholder = value; + this.stateChanges.next(); + } + get placeholder() { + return this._chipInput ? this._chipInput.placeholder : this._placeholder; + } + + /** Whether any chips or the mdChipInput inside of this chip-list has focus. */ + get focused() { + return this.chips.some(chip => chip._hasFocus) || + (this._chipInput && this._chipInput.focused); + } + + /** Whether this chip-list contains no chips and no mdChipInput. */ + get empty(): boolean { + return (!this._chipInput || this._chipInput.empty) && this.chips.length === 0; + } + + /** Whether this chip-list is disabled. */ + @Input() + get disabled() { return this.ngControl ? this.ngControl.disabled : this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } + + /** Whether the chip list is in an error state. */ + get errorState(): boolean { + const isInvalid = this.ngControl && this.ngControl.invalid; + const isTouched = this.ngControl && this.ngControl.touched; + const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || + (this._parentForm && this._parentForm.submitted); + return !!(isInvalid && (isTouched || isSubmitted)); + } /** Orientation of the chip list. */ @Input('aria-orientation') ariaOrientation: 'horizontal' | 'vertical' = 'horizontal'; - constructor(protected _renderer: Renderer2, protected _elementRef: ElementRef, - @Optional() private _dir: Directionality) { + /** + * Whether or not this chip is selectable. When a chip is not selectable, + * its selected state is always ignored. + */ + @Input() + get selectable(): boolean { return this._selectable; } + set selectable(value: boolean) { this._selectable = coerceBooleanProperty(value); } + + @Input() + set tabIndex(value: number) { + this._userTabIndex = value; + this._tabIndex = value; + } + + /** Combined stream of all of the child chips' selection change events. */ + get chipSelectionChanges(): Observable { + return merge(...this.chips.map(chip => chip.onSelectionChange)); + } + + /** Combined stream of all of the child chips' focus change events. */ + get chipFocusChanges(): Observable { + return merge(...this.chips.map(chip => chip._onFocus)); + } + + /** Combined stream of all of the child chips' remove change events. */ + get chipRemoveChanges(): Observable { + return merge(...this.chips.map(chip => chip.destroy)); + } + + /** Event emitted when the selected chip list value has been changed by the user. */ + @Output() change: EventEmitter = new EventEmitter(); + + /** + * Event that emits whenever the raw value of the chip-list changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private + */ + @Output() valueChange = new EventEmitter(); + + /** The chip components contained within this chip list. */ + @ContentChildren(MdChip) chips: QueryList; + + constructor(protected _renderer: Renderer2, + protected _elementRef: ElementRef, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() private _dir: Directionality, + @Optional() private _parentForm: NgForm, + @Optional() private _parentFormGroup: FormGroupDirective, + @Optional() @Self() public ngControl: NgControl) { + if (this.ngControl) { + this.ngControl.valueAccessor = this; + } } ngAfterContentInit(): void { + this._keyManager = new FocusKeyManager(this.chips).withWrap(); // Prevents the chip list from capturing focus and redirecting @@ -99,20 +308,12 @@ export class MdChipList implements AfterContentInit, OnDestroy { setTimeout(() => this._tabIndex = this._userTabIndex || 0); }); - // Go ahead and subscribe all of the initial chips - this._subscribeChips(this.chips); - - // Make sure we set our tab index at the start - this._updateTabIndex(); - // When the list changes, re-subscribe - this.chips.changes.subscribe((chips: QueryList) => { - this._subscribeChips(chips); + this._changeSubscription = startWith.call(this.chips.changes, null).subscribe(() => { + this._resetChips(); - // If we have 0 chips, attempt to focus an input (if available) - if (chips.length === 0) { - this._focusInput(); - } + // Reset chips selected/deselected status + this._initializeSelection(); // Check to see if we need to update our tab index this._updateTabIndex(); @@ -122,32 +323,51 @@ export class MdChipList implements AfterContentInit, OnDestroy { }); } + ngOnInit() { + this._selectionModel = new SelectionModel(true, undefined, false); + this.stateChanges.next(); + } + ngOnDestroy(): void { this._tabOutSubscription.unsubscribe(); + + if (this._changeSubscription) { + this._changeSubscription.unsubscribe(); + } + this._dropSubscriptions(); } - /** - * Whether or not this chip is selectable. When a chip is not selectable, - * it's selected state is always ignored. - */ - @Input() - get selectable(): boolean { - return this._selectable; + + /** Associates an HTML input element with this chip list. */ + registerInput(inputElement: MdChipInput) { + this._chipInput = inputElement; } - set selectable(value: boolean) { - this._selectable = coerceBooleanProperty(value); + // Implemented as part of MdFormFieldControl. + setDescribedByIds(ids: string[]) { this._ariaDescribedby = ids.join(' '); } + + // Implemented as part of ControlValueAccessor + writeValue(value: any): void { + if (this.chips) { + this._setSelectionByValue(value, false); + } } - @Input() - set tabIndex(value: number) { - this._userTabIndex = value; - this._tabIndex = value; + // Implemented as part of ControlValueAccessor + registerOnChange(fn: (value: any) => void): void { + this._onChange = fn; } - /** Associates an HTML input element with this chip list. */ - registerInput(inputElement: HTMLInputElement) { - this._inputElement = inputElement; + // Implemented as part of ControlValueAccessor + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + // Implemented as part of ControlValueAccessor + setDisabledState(disabled: boolean): void { + this.disabled = disabled; + this._renderer.setProperty(this._elementRef.nativeElement, 'disabled', disabled); + this.stateChanges.next(); } /** @@ -156,17 +376,22 @@ export class MdChipList implements AfterContentInit, OnDestroy { */ focus() { // TODO: ARIA says this should focus the first `selected` chip if any are selected. - if (this.chips.length > 0) { + // Focus on first element if there's no chipInput inside chip-list + if (this._chipInput && this._chipInput.focused) { + // do nothing + } else if (this.chips.length > 0) { this._keyManager.setFirstItemActive(); + this.stateChanges.next(); } else { this._focusInput(); + this.stateChanges.next(); } } /** Attempt to focus an input if we have one. */ _focusInput() { - if (this._inputElement) { - this._inputElement.focus(); + if (this._chipInput) { + this._chipInput.focus(); } } @@ -202,17 +427,9 @@ export class MdChipList implements AfterContentInit, OnDestroy { this._keyManager.onKeydown(event); } } + this.stateChanges.next(); } - /** - * Iterate through the list of chips and add them to our list of - * subscribed chips. - * - * @param chips The list of chips to be subscribed. - */ - protected _subscribeChips(chips: QueryList): void { - chips.forEach(chip => this._addChip(chip)); - } /** * Check the tab index as you should not be allowed to focus an empty list. @@ -223,51 +440,26 @@ export class MdChipList implements AfterContentInit, OnDestroy { } /** - * Add a specific chip to our subscribed list. If the chip has - * already been subscribed, this ensures it is only subscribed - * once. - * - * @param chip The chip to be subscribed (or checked for existing - * subscription). + * Update key manager's active item when chip is deleted. + * If the deleted chip is the last chip in chip list, focus the new last chip. + * Otherwise focus the next chip in the list. + * Save `_lastDestroyedIndex` so we can set the correct focus. */ - protected _addChip(chip: MdChip) { - // If we've already been subscribed to a parent, do nothing - if (this._chipSet.has(chip)) { - return; - } - - // Watch for focus events outside of the keyboard navigation - chip._onFocus.subscribe(() => { - let chipIndex: number = this.chips.toArray().indexOf(chip); - - if (this._isValidIndex(chipIndex)) { - this._keyManager.updateActiveItemIndex(chipIndex); - } - }); - - // On destroy, remove the item from our list, and setup our destroyed focus check - chip.destroy.subscribe(() => { - let chipIndex: number = this.chips.toArray().indexOf(chip); - if (this._isValidIndex(chipIndex)) { - if (chip._hasFocus) { - // Check whether the chip is the last item - if (chipIndex < this.chips.length - 1) { - this._keyManager.setActiveItem(chipIndex); - } else if (chipIndex - 1 >= 0) { - this._keyManager.setActiveItem(chipIndex - 1); - } + protected _updateKeyManager(chip: MdChip) { + let chipIndex: number = this.chips.toArray().indexOf(chip); + if (this._isValidIndex(chipIndex)) { + if (chip._hasFocus) { + // Check whether the chip is not the last item + if (chipIndex < this.chips.length - 1) { + this._keyManager.setActiveItem(chipIndex); + } else if (chipIndex - 1 >= 0) { + this._keyManager.setActiveItem(chipIndex - 1); } - if (this._keyManager.activeItemIndex === chipIndex) { - this._lastDestroyedIndex = chipIndex; - } - } - - this._chipSet.delete(chip); - chip.destroy.unsubscribe(); - }); - - this._chipSet.set(chip, true); + if (this._keyManager.activeItemIndex === chipIndex) { + this._lastDestroyedIndex = chipIndex; + } + } } /** @@ -282,11 +474,12 @@ export class MdChipList implements AfterContentInit, OnDestroy { const newFocusIndex = Math.min(this._lastDestroyedIndex, chipsArray.length - 1); this._keyManager.setActiveItem(newFocusIndex); let focusChip = this._keyManager.activeItem; - // Focus the chip if (focusChip) { focusChip.focus(); } + } else if (chipsArray.length === 0) { + this._focusInput(); } // Reset our destroyed index @@ -312,4 +505,156 @@ export class MdChipList implements AfterContentInit, OnDestroy { return false; } + + _setSelectionByValue(value: any, isUserInput: boolean = true) { + this._clearSelection(); + this.chips.forEach(chip => chip.deselect()); + if (Array.isArray(value)) { + value.forEach(currentValue => this._selectValue(currentValue, isUserInput)); + } + this._sortValues(); + } + + /** + * Finds and selects the chip based on its value. + * @returns Chip that has the corresponding value. + */ + private _selectValue(value: any, isUserInput: boolean = true): MdChip | undefined { + + const correspondingChip = this.chips.find(chip => { + return chip.value != null && this._compareWith(chip.value, value); + }); + + if (correspondingChip) { + isUserInput ? correspondingChip.selectViaInteraction() : correspondingChip.select(); + this._selectionModel.select(correspondingChip); + } + + return correspondingChip; + } + + private _initializeSelection(): void { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve().then(() => { + this._setSelectionByValue(this.ngControl ? this.ngControl.value : this._value, false); + this.stateChanges.next(); + }); + } + + /** + * Deselects every chip in the list. + * @param skip Chip that should not be deselected. + */ + private _clearSelection(skip?: MdChip): void { + this._selectionModel.clear(); + this.chips.forEach(chip => { + if (chip !== skip) { + chip.deselect(); + } + }); + this.stateChanges.next(); + } + + /** + * Sorts the model values, ensuring that they keep the same + * order that they have in the panel. + */ + private _sortValues(): void { + this._selectionModel.clear(); + + this.chips.forEach(chip => { + if (chip.selected) { + this._selectionModel.select(chip); + } + }); + this.stateChanges.next(); + } + + /** Emits change event to set the model value. */ + private _propagateChanges(): void { + let valueToEmit = this.selected.length === 0 ? null : this.selected.map(chip => chip.value); + this._value = valueToEmit; + this.change.emit(new MdChipListChange(this, valueToEmit)); + this.valueChange.emit(valueToEmit); + this._onChange(valueToEmit); + this._changeDetectorRef.markForCheck(); + } + + /** When blurred, mark the field as touched when focus moved outside the chip list. */ + _blur() { + if (!this.disabled) { + if (this._chipInput) { + // If there's a chip input, we should check whether the focus moved to chip input. + // If the focus is not moved to chip input, mark the field as touched. If the focus moved + // to chip input, do nothing. + // Timeout is needed to wait for the focus() event trigger on chip input. + setTimeout(() => { + if (!this.focused) { + this._markAsTouched(); + } + }); + } else { + // If there's no chip input, then mark the field as touched. + this._markAsTouched(); + } + } + } + + /** Mark the field as touched */ + _markAsTouched() { + this._onTouched(); + this._changeDetectorRef.markForCheck(); + this.stateChanges.next(); + } + + private _resetChips() { + this._dropSubscriptions(); + this._listenToChipsFocus(); + this._listenToChipsSelection(); + this._listenToChipsRemoved(); + } + + + private _dropSubscriptions() { + if (this._chipFocusSubscription) { + this._chipFocusSubscription.unsubscribe(); + this._chipFocusSubscription = null; + } + + if (this._chipSelectionSubscription) { + this._chipSelectionSubscription.unsubscribe(); + this._chipSelectionSubscription = null; + } + } + + /** Listens to user-generated selection events on each chip. */ + private _listenToChipsSelection(): void { + this._chipSelectionSubscription = this.chipSelectionChanges.subscribe(event => { + event.source.selected + ? this._selectionModel.select(event.source) + : this._selectionModel.deselect(event.source); + if (event.isUserInput) { + this._propagateChanges(); + } + }); + } + + /** Listens to user-generated selection events on each chip. */ + private _listenToChipsFocus(): void { + this._chipFocusSubscription = this.chipFocusChanges.subscribe(event => { + let chipIndex: number = this.chips.toArray().indexOf(event.chip); + + if (this._isValidIndex(chipIndex)) { + this._keyManager.updateActiveItemIndex(chipIndex); + } + this.stateChanges.next(); + }); + } + + private _listenToChipsRemoved(): void { + this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event) => { + this._updateKeyManager(event.chip); + }); + } } diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts index 90c3bdfa873f..cb8b6f506658 100644 --- a/src/lib/chips/chip.spec.ts +++ b/src/lib/chips/chip.spec.ts @@ -2,7 +2,7 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; import {createKeyboardEvent} from '@angular/cdk/testing'; -import {MdChipList, MdChip, MdChipEvent, MdChipsModule} from './index'; +import {MdChipList, MdChip, MdChipEvent, MdChipsModule, MdChipSelectionChange} from './index'; import {SPACE, DELETE, BACKSPACE} from '@angular/material/core'; import {Directionality} from '@angular/material/core'; @@ -113,14 +113,15 @@ describe('Chips', () => { }); it('allows selection', () => { - spyOn(testComponent, 'chipSelect'); + spyOn(testComponent, 'chipSelectionChange'); expect(chipNativeElement.classList).not.toContain('mat-chip-selected'); testComponent.selected = true; fixture.detectChanges(); expect(chipNativeElement.classList).toContain('mat-chip-selected'); - expect(testComponent.chipSelect).toHaveBeenCalledWith({chip: chipInstance}); + expect(testComponent.chipSelectionChange) + .toHaveBeenCalledWith({source: chipInstance, isUserInput: false}); }); it('allows removal', () => { @@ -143,26 +144,25 @@ describe('Chips', () => { it('should selects/deselects the currently focused chip on SPACE', () => { const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent; - const CHIP_EVENT: MdChipEvent = {chip: chipInstance}; + const CHIP_EVENT: MdChipSelectionChange = {source: chipInstance, isUserInput: true}; - spyOn(testComponent, 'chipSelect'); - spyOn(testComponent, 'chipDeselect'); + spyOn(testComponent, 'chipSelectionChange'); // Use the spacebar to select the chip chipInstance._handleKeydown(SPACE_EVENT); fixture.detectChanges(); expect(chipInstance.selected).toBeTruthy(); - expect(testComponent.chipSelect).toHaveBeenCalledTimes(1); - expect(testComponent.chipSelect).toHaveBeenCalledWith(CHIP_EVENT); + expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(1); + expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_EVENT); // Use the spacebar to deselect the chip chipInstance._handleKeydown(SPACE_EVENT); fixture.detectChanges(); expect(chipInstance.selected).toBeFalsy(); - expect(testComponent.chipDeselect).toHaveBeenCalledTimes(1); - expect(testComponent.chipDeselect).toHaveBeenCalledWith(CHIP_EVENT); + expect(testComponent.chipSelectionChange).toHaveBeenCalledTimes(2); + expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_EVENT); }); it('should have correct aria-selected', () => { @@ -184,14 +184,14 @@ describe('Chips', () => { it('SPACE ignores selection', () => { const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent; - spyOn(testComponent, 'chipSelect'); + spyOn(testComponent, 'chipSelectionChange'); // Use the spacebar to attempt to select the chip chipInstance._handleKeydown(SPACE_EVENT); fixture.detectChanges(); expect(chipInstance.selected).toBeFalsy(); - expect(testComponent.chipSelect).not.toHaveBeenCalled(); + expect(testComponent.chipSelectionChange).not.toHaveBeenCalled(); }); it('should not have the aria-selected attribute', () => { @@ -281,7 +281,7 @@ describe('Chips', () => { {{name}} @@ -299,8 +299,7 @@ class SingleChip { chipFocus: (event?: MdChipEvent) => void = () => {}; chipDestroy: (event?: MdChipEvent) => void = () => {}; - chipSelect: (event?: MdChipEvent) => void = () => {}; - chipDeselect: (event?: MdChipEvent) => void = () => {}; + chipSelectionChange: (event?: MdChipSelectionChange) => void = () => {}; chipRemove: (event?: MdChipEvent) => void = () => {}; } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 3a88bd516064..089d0e3ecc94 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -34,6 +34,12 @@ export interface MdChipEvent { chip: MdChip; } +/** Event object emitted by MdChip when selected or deselected. */ +export class MdChipSelectionChange { + constructor(public source: MdChip, public isUserInput = false) { } +} + + // Boilerplate for applying mixins to MdChip. /** @docs-private */ export class MdChipBase { @@ -72,79 +78,102 @@ export class MdBasicChip { } '(keydown)': '_handleKeydown($event)', '(focus)': '_hasFocus = true', '(blur)': '_hasFocus = false', - } + }, + }) export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestroy, CanColor, CanDisable { - @ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove; + protected _value: any; + + protected _selected: boolean = false; + + protected _selectable: boolean = true; + + protected _removable: boolean = true; + + /** Whether the chip has focus. */ + _hasFocus: boolean = false; /** Whether the chip is selected. */ - @Input() get selected(): boolean { return this._selected; } + @Input() + get selected(): boolean { return this._selected; } set selected(value: boolean) { this._selected = coerceBooleanProperty(value); - (this.selected ? this.select : this.deselect).emit({chip: this}); + this.onSelectionChange.emit({source: this, isUserInput: false}); } - protected _selected: boolean = false; + + /** The value of the chip. Defaults to the content inside tags. */ + @Input() + get value(): any { + return this._value != undefined + ? this._value + : this._elementRef.nativeElement.textContent.trim(); + } + set value(newValue: any) { this._value = newValue; } /** * Whether or not the chips are selectable. When a chip is not selectable, * changes to it's selected state are always ignored. */ - @Input() get selectable(): boolean { - return this._selectable; - } - - set selectable(value: boolean) { - this._selectable = coerceBooleanProperty(value); - } - protected _selectable: boolean = true; + @Input() + get selectable(): boolean { return this._selectable; } + set selectable(value: boolean) { this._selectable = coerceBooleanProperty(value); } /** * Determines whether or not the chip displays the remove styling and emits (remove) events. */ - @Input() get removable(): boolean { - return this._removable; - } - - set removable(value: boolean) { - this._removable = coerceBooleanProperty(value); - } - protected _removable: boolean = true; - - /** Whether the chip has focus. */ - _hasFocus: boolean = false; + @Input() + get removable(): boolean { return this._removable; } + set removable(value: boolean) { this._removable = coerceBooleanProperty(value); } /** Emits when the chip is focused. */ _onFocus = new Subject(); - /** Emitted when the chip is selected. */ - @Output() select = new EventEmitter(); - - /** Emitted when the chip is deselected. */ - @Output() deselect = new EventEmitter(); + /** Emitted when the chip is selected or deselected. */ + @Output() onSelectionChange = new EventEmitter(); /** Emitted when the chip is destroyed. */ @Output() destroy = new EventEmitter(); + /** Emitted when a chip is to be removed. */ + @Output('remove') onRemove = new EventEmitter(); + get ariaSelected(): string | null { return this.selectable ? this.selected.toString() : null; } - constructor(renderer: Renderer2, elementRef: ElementRef) { - super(renderer, elementRef); + constructor(renderer: Renderer2, public _elementRef: ElementRef) { + super(renderer, _elementRef); } - /** Emitted when a chip is to be removed. */ - @Output('remove') onRemove = new EventEmitter(); - ngOnDestroy(): void { this.destroy.emit({chip: this}); } + /** Selects the chip. */ + select(): void { + this._selected = true; + this.onSelectionChange.emit({source: this, isUserInput: false}); + } + + /** Deselects the chip. */ + deselect(): void { + this._selected = false; + this.onSelectionChange.emit({source: this, isUserInput: false}); + } + + /** Select this chip and emit selected event */ + selectViaInteraction() { + this._selected = true; + // Emit select event when selected changes. + this.onSelectionChange.emit({source: this, isUserInput: true}); + } + /** Toggles the current selected state of this chip. */ - toggleSelected(): boolean { - this.selected = !this.selected; + toggleSelected(isUserInput: boolean = false): boolean { + this._selected = !this.selected; + this.onSelectionChange.emit({source: this, isUserInput}); return this.selected; } @@ -196,7 +225,7 @@ export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestr case SPACE: // If we are selectable, toggle the focused chip if (this.selectable) { - this.toggleSelected(); + this.toggleSelected(true); } // Always prevent space from scrolling the page since the list has focus diff --git a/src/lib/chips/chips.md b/src/lib/chips/chips.md index 1e26923b5b79..839f592dafa3 100644 --- a/src/lib/chips/chips.md +++ b/src/lib/chips/chips.md @@ -22,13 +22,38 @@ _Hint: `` receives the `mat-basic-chip` CSS class in addition to Chips can be selected via the `selected` property. Selection can be disabled by setting `selectable` to `false` on the ``. -Selection emits the `(select)` output while deselecting emits the `(deselect)` output. Both outputs -receive a ChipEvent object with a structure of `{ chip: alteredChip }`. +Selection and deselecting emit the `(onSelectionChange)` output. The output receive a +ChipSelectionChange object with a structure of `{ source: alteredChip, isUserInput: boolean }`. ### Disabled chips Individual chips may be disabled by applying the `disabled` attribute to the chip. When disabled, chips are neither selectable nor focusable. Currently, disabled chips receive no special styling. +### Chip input +Chip input can work with chip list to add new chips to the chip list. It implements chip-specified +behaviors to an input element inside ``. Chip input may be placed inside or outside of +an ``. + +```html + + + Chip 1 + Chip 2 + + + +``` + +```html + + + Chip 1 + Chip 2 + + + +``` + ### Keyboard interaction Users can move through the chips using the arrow keys and select/deselect them with the space. Chips also gain focus when clicked, ensuring keyboard navigation starts at the appropriate chip. diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss index b170a27039e1..478caf88d71f 100644 --- a/src/lib/chips/chips.scss +++ b/src/lib/chips/chips.scss @@ -7,12 +7,16 @@ $mat-chip-remove-margin-before: 6px; $mat-chip-remove-margin-after: -4px; $mat-chips-chip-margin: 8px; +$mat-chips-chip-bottom-margin: 3px; + +$mat-chip-input-width: 150px; +$mat-chip-input-margin: 3px; .mat-chip-list-wrapper { display: flex; flex-direction: row; flex-wrap: wrap; - align-items: flex-start; + align-items: baseline; } .mat-chip:not(.mat-basic-chip) { @@ -25,10 +29,10 @@ $mat-chips-chip-margin: 8px; // Apply a margin to adjacent sibling chips. & + & { - margin: 0 0 0 $mat-chips-chip-margin; + margin: 0 0 $mat-chips-chip-bottom-margin $mat-chips-chip-margin; [dir='rtl'] & { - margin: 0 $mat-chips-chip-margin 0 0; + margin: 0 $mat-chips-chip-margin $mat-chips-chip-bottom-margin 0; } } @@ -90,3 +94,8 @@ $mat-chips-chip-margin: 8px; margin-left: $mat-chip-remove-margin-after; } } + +input.mat-chip-input { + width: $mat-chip-input-width; + margin: $mat-chip-input-margin; +} diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index 8a3df761e601..c3a3c72f3d07 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -69,13 +69,13 @@ let nextUniqueId = 0; }) export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, DoCheck { /** Variables used as cache for getters and setters. */ - private _type = 'text'; - private _disabled = false; - private _required = false; - private _id: string; - private _uid = `md-input-${nextUniqueId++}`; - private _errorOptions: ErrorOptions; - private _previousNativeValue = this.value; + protected _type = 'text'; + protected _disabled = false; + protected _required = false; + protected _id: string; + protected _uid = `md-input-${nextUniqueId++}`; + protected _errorOptions: ErrorOptions; + protected _previousNativeValue = this.value; /** Whether the input is focused. */ focused = false; @@ -137,7 +137,7 @@ export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, D } } - private _neverEmptyInputTypes = [ + protected _neverEmptyInputTypes = [ 'date', 'datetime', 'datetime-local', @@ -146,12 +146,12 @@ export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, D 'week' ].filter(t => getSupportedInputTypes().has(t)); - constructor(private _elementRef: ElementRef, - private _renderer: Renderer2, - private _platform: Platform, + constructor(protected _elementRef: ElementRef, + protected _renderer: Renderer2, + protected _platform: Platform, @Optional() @Self() public ngControl: NgControl, - @Optional() private _parentForm: NgForm, - @Optional() private _parentFormGroup: FormGroupDirective, + @Optional() protected _parentForm: NgForm, + @Optional() protected _parentFormGroup: FormGroupDirective, @Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { // Force setter to be called in case id was not specified. @@ -216,7 +216,7 @@ export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, D } /** Re-evaluates the error state. This is only relevant with @angular/forms. */ - private _updateErrorState() { + protected _updateErrorState() { const oldState = this.errorState; const ngControl = this.ngControl; const parent = this._parentFormGroup || this._parentForm; @@ -229,7 +229,7 @@ export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, D } /** Does some manual dirty checking on the native input `value` property. */ - private _dirtyCheckNativeValue() { + protected _dirtyCheckNativeValue() { const newValue = this.value; if (this._previousNativeValue !== newValue) { @@ -239,26 +239,26 @@ export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, D } /** Make sure the input is a supported type. */ - private _validateType() { + protected _validateType() { if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) { throw getMdInputUnsupportedTypeError(this._type); } } /** Checks whether the input type is one of the types that are never empty. */ - private _isNeverEmpty() { + protected _isNeverEmpty() { return this._neverEmptyInputTypes.indexOf(this._type) > -1; } /** Checks whether the input is invalid based on the native validation. */ - private _isBadInput() { + protected _isBadInput() { // The `validity` property won't be present on platform-server. let validity = (this._elementRef.nativeElement as HTMLInputElement).validity; return validity && validity.badInput; } /** Determines if the component host is a textarea. If not recognizable it returns false. */ - private _isTextarea() { + protected _isTextarea() { let nativeElement = this._elementRef.nativeElement; // In Universal, we don't have access to `nodeName`, but the same can be achieved with `name`. From ae03bfed94283b215ce6cd72b4b70164bc98d25b Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Thu, 31 Aug 2017 18:26:36 -0700 Subject: [PATCH 2/6] Add multiple selection and single selection to chip list --- src/demo-app/chips/chips-demo.html | 13 +- src/demo-app/chips/chips-demo.ts | 1 + src/lib/chips/chip-list.spec.ts | 338 +++++++++++++++++++---------- src/lib/chips/chip-list.ts | 65 ++++-- 4 files changed, 292 insertions(+), 125 deletions(-) diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 3ada94d8eee0..4673b48b256a 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -128,7 +128,7 @@

Stacked Chips

NgModel with chip list

- + {{aColor.name}} @@ -137,6 +137,17 @@

NgModel with chip list

The selected colors are {{color}}. + +

NgModel with single selection chip list

+ + + {{aColor.name}} + cancel + + + + The selected color is {{selectedColor}}. diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts index 8f63a4f3efd9..4d5995aed4a3 100644 --- a/src/demo-app/chips/chips-demo.ts +++ b/src/demo-app/chips/chips-demo.ts @@ -94,4 +94,5 @@ export class ChipsDemo { this.visible = false; } selectedColors: any[] = ['Primary', 'Warn']; + selectedColor = 'Accent'; } diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index 3832fb40380f..e20f1aecec3c 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -53,6 +53,7 @@ describe('MdChipList', () => { FormFieldChipList, BasicChipList, InputChipList, + MultiSelectionChipList, FalsyValueChipList, ], providers: [{ @@ -363,15 +364,14 @@ describe('MdChipList', () => { fixture.detectChanges(); expect(instanceChips.first.selected).toBe(true, 'Expected first option to be selected.'); - expect(chipList.selected.length).toBe(1, 'Expect one option to be selected'); - expect(chipList.selected[0]).toBe(chips.first, 'Expected first option to be selected.'); + expect(chipList.selected).toBe(chips.first, 'Expected first option to be selected.'); fixture.componentInstance.foods = []; fixture.detectChanges(); fixture.whenStable().then(() => { - expect(chipList.selected.length) - .toBe(0, 'Expected selection to be removed when option no longer exists.'); + expect(chipList.selected) + .toBe(undefined, 'Expected selection to be removed when option no longer exists.'); }); })); @@ -401,8 +401,8 @@ describe('MdChipList', () => { expect(fixture.componentInstance.chipList.value) .toBeUndefined('Expect value to be undefined'); expect(array[2].selected).toBeFalsy('Expect disabled chip not selected'); - expect(fixture.componentInstance.chipList.selected.length) - .toBe(0, 'Expect no selected chips'); + expect(fixture.componentInstance.chipList.selected) + .toBeUndefined('Expect no selected chips'); }); }); @@ -411,155 +411,238 @@ describe('MdChipList', () => { let formField: HTMLElement; let nativeChips: HTMLElement[]; - beforeEach(() => { - fixture = TestBed.createComponent(BasicChipList); - fixture.detectChanges(); + describe('single selection', () => { + beforeEach(() => { + fixture = TestBed.createComponent(BasicChipList); + fixture.detectChanges(); - formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; - nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) + formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; + nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) .map((chip) => chip.nativeElement); - chips = fixture.componentInstance.chips; - }); + chips = fixture.componentInstance.chips; + }); - it('should take an initial view value with reactive forms', () => { - fixture.componentInstance.control = new FormControl(['pizza-1']); - fixture.detectChanges(); + it('should take an initial view value with reactive forms', () => { + fixture.componentInstance.control = new FormControl('pizza-1'); + fixture.detectChanges(); - const array = chips.toArray(); + const array = chips.toArray(); - expect(array[1].selected).toBeTruthy('Expect pizza-1 chip to be selected'); + expect(array[1].selected).toBeTruthy('Expect pizza-1 chip to be selected'); - dispatchKeyboardEvent(nativeChips[1], 'keydown', SPACE); - fixture.detectChanges(); + dispatchKeyboardEvent(nativeChips[1], 'keydown', SPACE); + fixture.detectChanges(); - expect(array[1].selected).toBeFalsy('Expect chip to be not selected after toggle selected'); - }); + expect(array[1].selected).toBeFalsy('Expect chip to be not selected after toggle selected'); + }); - it('should set the view value from the form', () => { - const chipList = fixture.componentInstance.chipList; - const array = chips.toArray(); + it('should set the view value from the form', () => { + const chipList = fixture.componentInstance.chipList; + const array = chips.toArray(); - expect(chipList.value).toBeFalsy('Expect chip list to have no initial value'); + expect(chipList.value).toBeFalsy('Expect chip list to have no initial value'); - fixture.componentInstance.control.setValue(['pizza-1']); - fixture.detectChanges(); + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); - expect(array[1].selected).toBeTruthy('Expect chip to be selected'); - }); + expect(array[1].selected).toBeTruthy('Expect chip to be selected'); + }); - it('should update the form value when the view changes', () => { + 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.`); + expect(fixture.componentInstance.control.value) + .toEqual(null, `Expected the control's value to be empty initially.`); - dispatchKeyboardEvent(nativeChips[0], 'keydown', SPACE); - fixture.detectChanges(); + 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.`); - }); + 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(); + it('should clear the selection when a nonexistent option value is selected', () => { + const array = chips.toArray(); - fixture.componentInstance.control.setValue(['pizza-1']); - fixture.detectChanges(); + fixture.componentInstance.control.setValue('pizza-1'); + fixture.detectChanges(); - expect(array[1].selected) - .toBeTruthy(`Expected chip with the value to be selected.`); + expect(array[1].selected) + .toBeTruthy(`Expected chip with the value to be selected.`); - fixture.componentInstance.control.setValue(['gibberish']); + fixture.componentInstance.control.setValue('gibberish'); - fixture.detectChanges(); + fixture.detectChanges(); - expect(array[1].selected) - .toBeFalsy(`Expected chip with the old value not to be selected.`); - }); + 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(); + 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.setValue('pizza-1'); + fixture.detectChanges(); - fixture.componentInstance.control.reset(); - fixture.detectChanges(); + fixture.componentInstance.control.reset(); + fixture.detectChanges(); - expect(array[1].selected) - .toBeFalsy(`Expected chip with the old value not to be selected.`); - }); + 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 list is touched', () => { - expect(fixture.componentInstance.control.touched) - .toBe(false, 'Expected the control to start off as untouched.'); + it('should set the control to touched when the chip list is touched', () => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); - const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; - dispatchFakeEvent(nativeChipList, 'blur'); + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + dispatchFakeEvent(nativeChipList, 'blur'); - expect(fixture.componentInstance.control.touched) - .toBe(true, 'Expected the control to be touched.'); - }); + expect(fixture.componentInstance.control.touched) + .toBe(true, 'Expected the control to be touched.'); + }); - it('should not set touched when a disabled chip list is touched', () => { - expect(fixture.componentInstance.control.touched) - .toBe(false, 'Expected the control to start off as untouched.'); + it('should not set touched when a disabled chip list is touched', () => { + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to start off as untouched.'); - fixture.componentInstance.control.disable(); - const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; - dispatchFakeEvent(nativeChipList, 'blur'); + fixture.componentInstance.control.disable(); + const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; + dispatchFakeEvent(nativeChipList, 'blur'); - expect(fixture.componentInstance.control.touched) - .toBe(false, 'Expected the control to stay untouched.'); - }); + expect(fixture.componentInstance.control.touched) + .toBe(false, 'Expected the control to stay untouched.'); + }); - it('should set the control to dirty when the chip list\'s value changes in the DOM', () => { - expect(fixture.componentInstance.control.dirty) - .toEqual(false, `Expected control to start out pristine.`); + it('should set the control to dirty when the chip list\'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(); + 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.`); - }); + 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.`); + 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']); + fixture.componentInstance.control.setValue('pizza-1'); - expect(fixture.componentInstance.control.dirty) - .toEqual(false, `Expected control to stay pristine after programmatic change.`); - }); + 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.`); + 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(); + 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.`); + 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 be able to programmatically select a falsy option', () => { + fixture.destroy(); + + const falsyFixture = TestBed.createComponent(FalsyValueChipList); + 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 be able to programmatically select a falsy option', () => { - fixture.destroy(); + describe('multiple selection', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MultiSelectionChipList); + fixture.detectChanges(); + + formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; + nativeChips = fixture.debugElement.queryAll(By.css('md-chip')) + .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 chipList = fixture.componentInstance.chipList; + const array = chips.toArray(); + + expect(chipList.value).toBeFalsy('Expect chip list 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.`); + }); - const falsyFixture = TestBed.createComponent(FalsyValueChipList); - falsyFixture.detectChanges(); - falsyFixture.componentInstance.control.setValue([0]); - falsyFixture.detectChanges(); - falsyFixture.detectChanges(); + it('should clear the selection when the control is reset', () => { + const array = chips.toArray(); - expect(falsyFixture.componentInstance.chips.first.selected) - .toBe(true, 'Expected first option to be selected'); + 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.`); + }); }); }); @@ -649,7 +732,6 @@ describe('MdChipList', () => { .toBe(false, 'Expected the control to start off as untouched.'); const nativeChipList = fixture.debugElement.query(By.css('.mat-chip-list')).nativeElement; - const nativeInput = fixture.debugElement.query(By.css('.mat-chip-input')).nativeElement; dispatchFakeEvent(nativeChipList, 'blur'); @@ -850,11 +932,47 @@ class BasicChipList { @ViewChildren(MdChip) chips: QueryList; } + +@Component({ + selector: 'multi-selection-chip-list', + template: ` + + + + {{ food.viewValue }} + + + + ` +}) +class MultiSelectionChipList { + 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(MdChipList) chipList: MdChipList; + @ViewChildren(MdChip) chips: QueryList; +} + @Component({ selector: 'input-chip-list', template: ` - + {{ food.viewValue }} diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 443122120350..4ce8acb597dc 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -64,7 +64,7 @@ export class MdChipListChange { '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', '[attr.aria-invalid]': 'errorState', - '[attr.aria-multiselectable]': 'true', + '[attr.aria-multiselectable]': 'multiple', '[class.mat-chip-list-disabled]': 'disabled', '[class.mat-chip-list-invalid]': 'errorState', '[class.mat-chip-list-required]': 'required', @@ -113,6 +113,9 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor /** Whether or not the chip is selectable. */ protected _selectable: boolean = true; + /** Whether the component is in multiple selection mode. */ + private _multiple: boolean = false; + /** The chip input to add more chips */ protected _chipInput: MdChipInput; @@ -160,8 +163,15 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor private _compareWith = (o1: any, o2: any) => o1 === o2; /** The array of selected chips inside chip list. */ - get selected(): MdChip[] { - return this._selectionModel.selected; + get selected(): MdChip[] | MdChip { + return this.multiple ? this._selectionModel.selected : this._selectionModel.selected[0]; + } + + /** Whether the user should be allowed to select multiple chips. */ + @Input() + get multiple(): boolean { return this._multiple; } + set multiple(value: boolean) { + this._multiple = coerceBooleanProperty(value); } /** @@ -324,7 +334,7 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor } ngOnInit() { - this._selectionModel = new SelectionModel(true, undefined, false); + this._selectionModel = new SelectionModel(this.multiple, undefined, false); this.stateChanges.next(); } @@ -509,10 +519,19 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor _setSelectionByValue(value: any, isUserInput: boolean = true) { this._clearSelection(); this.chips.forEach(chip => chip.deselect()); + if (Array.isArray(value)) { value.forEach(currentValue => this._selectValue(currentValue, isUserInput)); + this._sortValues(); + } else { + const correspondingChip = this._selectValue(value, isUserInput); + + // Shift focus to the active item. Note that we shouldn't do this in multiple + // mode, because we don't know what chip the user interacted with last. + if (correspondingChip) { + this._keyManager.setActiveItem(this.chips.toArray().indexOf(correspondingChip)); + } } - this._sortValues(); } /** @@ -561,19 +580,27 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor * order that they have in the panel. */ private _sortValues(): void { - this._selectionModel.clear(); + if (this._multiple) { + this._selectionModel.clear(); - this.chips.forEach(chip => { - if (chip.selected) { - this._selectionModel.select(chip); - } - }); - this.stateChanges.next(); + this.chips.forEach(chip => { + if (chip.selected) { + this._selectionModel.select(chip); + } + }); + this.stateChanges.next(); + } } /** Emits change event to set the model value. */ - private _propagateChanges(): void { - let valueToEmit = this.selected.length === 0 ? null : this.selected.map(chip => chip.value); + private _propagateChanges(fallbackValue?: any): void { + 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 MdChipListChange(this, valueToEmit)); this.valueChange.emit(valueToEmit); @@ -634,6 +661,16 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor event.source.selected ? this._selectionModel.select(event.source) : this._selectionModel.deselect(event.source); + + // For single selection chip list, make sure the deselected value is unselected. + if (!this.multiple) { + this.chips.forEach(chip => { + if (!this._selectionModel.isSelected(chip) && chip.selected) { + chip.deselect(); + } + }); + } + if (event.isUserInput) { this._propagateChanges(); } From fc5f6e9ca70205ec9f6e73a1c5df26dff7c1316c Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Tue, 5 Sep 2017 15:55:57 -0700 Subject: [PATCH 3/6] try fix test --- src/lib/chips/chip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 089d0e3ecc94..4a9702eca973 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -108,7 +108,7 @@ export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestr get value(): any { return this._value != undefined ? this._value - : this._elementRef.nativeElement.textContent.trim(); + : this._elementRef.nativeElement.textContent; } set value(newValue: any) { this._value = newValue; } From 15106aafb2089def322793cd1fc1bc9fc59331a6 Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Wed, 13 Sep 2017 10:25:56 -0700 Subject: [PATCH 4/6] not extend from MdInput --- src/lib/chips/chip-input.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/lib/chips/chip-input.ts b/src/lib/chips/chip-input.ts index 7a8902329ef4..1c8aa79e6424 100644 --- a/src/lib/chips/chip-input.ts +++ b/src/lib/chips/chip-input.ts @@ -12,12 +12,7 @@ import { Output, EventEmitter, Input, - Optional, - Renderer2, - Self, } from '@angular/core'; -import {FormGroupDirective, NgControl, NgForm} from '@angular/forms'; -import {Platform} from '@angular/cdk/platform'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {ENTER} from '@angular/material/core'; import {MdChipList} from './chip-list'; @@ -87,19 +82,14 @@ export class MdChipInput { @Input() placeholder: string = ''; get empty(): boolean { - let value: string | null = this._elementRef.nativeElement.value; + let value: string | null = this._inputElement.value; return value == null || value === ''; } /** The native input element to which this directive is attached. */ protected _inputElement: HTMLInputElement; - constructor(protected _elementRef: ElementRef, - protected _renderer: Renderer2, - protected _platform: Platform, - @Optional() @Self() public ngControl: NgControl, - @Optional() protected _parentForm: NgForm, - @Optional() protected _parentFormGroup: FormGroupDirective) { + constructor(protected _elementRef: ElementRef) { this._inputElement = this._elementRef.nativeElement as HTMLInputElement; } @@ -140,5 +130,5 @@ export class MdChipInput { } } - focus() { this._elementRef.nativeElement.focus(); } + focus() { this._inputElement.focus(); } } From 1bfa13e1c78113d211b0f3b1115d282f46e77b0b Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Wed, 13 Sep 2017 18:00:38 -0700 Subject: [PATCH 5/6] Fix blur & focus behavior --- src/lib/chips/chip-list.spec.ts | 7 +------ src/lib/chips/chip-list.ts | 18 ++++++++++++++++++ src/lib/chips/chip.ts | 12 +++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index e20f1aecec3c..947c59071f10 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -1,13 +1,8 @@ -<<<<<<< HEAD import {FocusKeyManager} from '@angular/cdk/a11y'; import {createKeyboardEvent, dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing'; import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; -import { - FormControl, - FormsModule, - ReactiveFormsModule, -} from '@angular/forms'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import { BACKSPACE, DELETE, diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 4ce8acb597dc..24d3b48e3046 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -104,6 +104,9 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor /** Subscription to focus changes in the chips. */ private _chipFocusSubscription: Subscription|null; + /** Subscription to blur changes in the chips. */ + private _chipBlurSubscription: Subscription|null; + /** Subscription to selection changes in chips. */ private _chipSelectionSubscription: Subscription|null; @@ -277,6 +280,11 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor return merge(...this.chips.map(chip => chip._onFocus)); } + /** Combined stream of all of the child chips' blur change events. */ + get chipBlurChanges(): Observable { + return merge(...this.chips.map(chip => chip._onBlur)); + } + /** Combined stream of all of the child chips' remove change events. */ get chipRemoveChanges(): Observable { return merge(...this.chips.map(chip => chip.destroy)); @@ -649,6 +657,11 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor this._chipFocusSubscription = null; } + if (this._chipBlurSubscription) { + this._chipBlurSubscription.unsubscribe(); + this._chipBlurSubscription = null; + } + if (this._chipSelectionSubscription) { this._chipSelectionSubscription.unsubscribe(); this._chipSelectionSubscription = null; @@ -687,6 +700,11 @@ export class MdChipList implements MdFormFieldControl, ControlValueAccessor } this.stateChanges.next(); }); + + this._chipBlurSubscription = this.chipBlurChanges.subscribe(_ => { + this._blur(); + this.stateChanges.next(); + }); } private _listenToChipsRemoved(): void { diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 4a9702eca973..d9c1b0cbccdc 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -9,11 +9,9 @@ import {FocusableOption} from '@angular/cdk/a11y'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import { - ContentChild, Directive, ElementRef, EventEmitter, - forwardRef, Input, OnDestroy, Output, @@ -77,7 +75,7 @@ export class MdBasicChip { } '(click)': '_handleClick($event)', '(keydown)': '_handleKeydown($event)', '(focus)': '_hasFocus = true', - '(blur)': '_hasFocus = false', + '(blur)': '_blur()', }, }) @@ -130,6 +128,9 @@ export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestr /** Emits when the chip is focused. */ _onFocus = new Subject(); + /** Emits when the chip is blured. */ + _onBlur = new Subject(); + /** Emitted when the chip is selected or deselected. */ @Output() onSelectionChange = new EventEmitter(); @@ -233,6 +234,11 @@ export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestr break; } } + + _blur() { + this._hasFocus = false; + this._onBlur.next({chip: this}); + } } From 9cb4713cb68851ffc40cb24d8d5068b9f22bfff1 Mon Sep 17 00:00:00 2001 From: Yuan Gao Date: Fri, 15 Sep 2017 10:35:57 -0700 Subject: [PATCH 6/6] . --- src/lib/chips/chip-list.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 24d3b48e3046..d101dc45669b 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -5,7 +5,10 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ - +import {FocusKeyManager} from '@angular/cdk/a11y'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {SelectionModel} from '@angular/cdk/collections'; +import {startWith} from '@angular/cdk/rxjs'; import { AfterContentInit, ChangeDetectorRef, @@ -25,21 +28,23 @@ import { ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import { + BACKSPACE, + DELETE, + Directionality, + LEFT_ARROW, + RIGHT_ARROW, + UP_ARROW +} from '@angular/material/core'; +import {MdFormFieldControl} from '@angular/material/form-field'; -import {FocusKeyManager} from '@angular/cdk/a11y'; -import {BACKSPACE, DELETE, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '@angular/material/core'; -import {Directionality} from '@angular/cdk/bidi'; -import {SelectionModel} from '@angular/cdk/collections'; -import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import {Subject} from 'rxjs/Subject'; -import {startWith} from '@angular/cdk/rxjs'; import {Observable} from 'rxjs/Observable'; import {merge} from 'rxjs/observable/merge'; +import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; import {MdChip, MdChipEvent, MdChipSelectionChange} from './chip'; import {MdChipInput} from './chip-input'; -import {MdFormFieldControl} from '../form-field/form-field-control'; // Increasing integer for generating unique ids for chip-list components. let nextUniqueId = 0;