feat(chip-list): implement FormFieldControl and ControlValueAccessor (#6686)

BREAKING CHANGES:
 - Deprecated: @Output() select, and @Output() deselect
 - Added @Output() onSelectionChange = EventEmitter<MdChipSelectionChange>
This commit is contained in:
tinayuangao
2017-09-18 10:28:52 -07:00
committed by Jeremy Elbourn
parent 6fa9e6f88b
commit 7a42706614
12 changed files with 1427 additions and 217 deletions
+57 -17
View File
@@ -45,20 +45,22 @@
<code>&lt;md-form-field&gt;</code>.
</p>
<md-form-field>
<md-chip-list mdPrefix #chipList1>
<md-chip *ngFor="let person of people" [color]="color" [selectable]="selectable"
<button md-button (click)="tabIndex = (tabIndex === 0 ? -1 : 0)">
Set tabIndex to {{tabIndex === 0 ? -1 : 0}}
</button>
<md-form-field class="has-chip-list">
<md-chip-list [tabIndex]="tabIndex" #chipList1 [(ngModel)]="selectedPeople" required>
<md-chip *ngFor="let person of people" [color]="color" [selectable]="selectable"
[removable]="removable" (remove)="remove(person)">
{{person.name}}
<md-icon mdChipRemove *ngIf="removable">cancel</md-icon>
</md-chip>
<input placeholder="New Contributor..."
[mdChipInputFor]="chipList1"
[mdChipInputSeparatorKeyCodes]="separatorKeysCodes"
[mdChipInputAddOnBlur]="false"
(mdChipInputTokenEnd)="add($event)" />
</md-chip-list>
<input mdInput placeholder="New Contributor..."
[mdChipInputFor]="chipList1"
[mdChipInputSeparatorKeyCodes]="separatorKeysCodes"
[mdChipInputAddOnBlur]="addOnBlur"
(mdChipInputTokenEnd)="add($event)" />
</md-form-field>
@@ -68,21 +70,37 @@
With <code>mdChipInput</code> the input work with chip-list
</p>
<md-chip-list #chipList2>
<md-chip *ngFor="let person of people" [color]="color" [selectable]="selectable"
[removable]="removable" (remove)="remove(person)">
{{person.name}}
<md-icon mdChipRemove *ngIf="removable">cancel</md-icon>
</md-chip>
</md-chip-list>
<md-form-field>
<input mdInput placeholder="New Contributor..."
<md-chip-list [tabIndex]="tabIndex" #chipList2 [(ngModel)]="selectedPeople" required>
<md-chip *ngFor="let person of people" [color]="color" [selectable]="selectable"
[removable]="removable" (remove)="remove(person)">
{{person.name}}
<md-icon mdChipRemove *ngIf="removable">cancel</md-icon>
</md-chip>
</md-chip-list>
<input placeholder="New Contributor..."
[mdChipInputFor]="chipList2"
[mdChipInputSeparatorKeyCodes]="separatorKeysCodes"
[mdChipInputAddOnBlur]="addOnBlur"
(mdChipInputTokenEnd)="add($event)" />
</md-form-field>
<p>
Chips list without input
</p>
<md-form-field class="has-chip-list">
<md-chip-list #chipList3>
<md-chip *ngFor="let person of people" [color]="color" [selectable]="selectable"
[removable]="removable" (remove)="remove(person)">
{{person.name}}
<md-icon mdChipRemove *ngIf="removable">cancel</md-icon>
</md-chip>
</md-chip-list>
</md-form-field>
<p>
The example above has overridden the <code>[separatorKeys]</code> input to allow for
<code>ENTER</code>, <code>COMMA</code> and <code>SEMI COLON</code> keys.
@@ -108,6 +126,28 @@
{{aColor.name}}
</md-chip>
</md-chip-list>
<h4>NgModel with chip list</h4>
<md-chip-list [multiple]="true" [(ngModel)]="selectedColors">
<md-chip *ngFor="let aColor of availableColors" color="{{aColor.color}}"
[value]="aColor.name" (remove)="removeColor(aColor)">
{{aColor.name}}
<md-icon mdChipRemove>cancel</md-icon>
</md-chip>
</md-chip-list>
The selected colors are <span *ngFor="let color of selectedColors"> {{color}}</span>.
<h4>NgModel with single selection chip list</h4>
<md-chip-list [(ngModel)]="selectedColor">
<md-chip *ngFor="let aColor of availableColors" color="{{aColor.color}}"
[value]="aColor.name" (remove)="removeColor(aColor)">
{{aColor.name}}
<md-icon mdChipRemove>cancel</md-icon>
</md-chip>
</md-chip-list>
The selected color is {{selectedColor}}.
</md-card-content>
</md-card>
</div>
+4
View File
@@ -25,3 +25,7 @@
width: 150px;
}
}
.has-chip-list {
width: 100%;
}
+19
View File
@@ -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,23 @@ 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'];
selectedColor = 'Accent';
}
+2 -1
View File
@@ -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: () => {
+36 -5
View File
@@ -6,7 +6,13 @@
* 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,
} from '@angular/core';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {ENTER} from '@angular/material/core';
import {MdChipList} from './chip-list';
@@ -16,16 +22,21 @@ export interface MdChipInputEvent {
value: string;
}
/**
* Directive that adds chip-specific behaviors to an input element inside <md-form-field>.
* May be placed inside or outside of an <md-chip-list>.
*/
@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 +44,7 @@ export class MdChipInput {
set chipList(value: MdChipList) {
if (value) {
this._chipList = value;
this._chipList.registerInput(this._inputElement);
this._chipList.registerInput(this);
}
}
@@ -68,6 +79,13 @@ 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._inputElement.value;
return value == null || value === '';
}
/** The native input element to which this directive is attached. */
protected _inputElement: HTMLInputElement;
@@ -85,6 +103,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 +129,6 @@ export class MdChipInput {
}
}
}
focus() { this._inputElement.focus(); }
}
+670 -4
View File
@@ -1,13 +1,16 @@
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 +19,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 +35,21 @@ 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,
MultiSelectionChipList,
FalsyValueChipList,
],
providers: [{
provide: Directionality, useFactory: () => {
@@ -311,6 +327,512 @@ 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).toBe(chips.first, 'Expected first option to be selected.');
fixture.componentInstance.foods = [];
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(chipList.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('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)
.toBeUndefined('Expect no selected chips');
});
});
describe('forms integration', () => {
let formField: HTMLElement;
let nativeChips: HTMLElement[];
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'))
.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('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.`);
});
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.`);
});
});
});
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;
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 +892,147 @@ class StandardChipList {
})
class FormFieldChipList {
}
@Component({
selector: 'basic-chip-list',
template: `
<md-form-field>
<md-chip-list placeholder="Food" [formControl]="control" [required]="isRequired"
[tabIndex]="tabIndexOverride" [selectable]="selectable">
<md-chip *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
{{ food.viewValue }}
</md-chip>
</md-chip-list>
</md-form-field>
`
})
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<MdChip>;
}
@Component({
selector: 'multi-selection-chip-list',
template: `
<md-form-field>
<md-chip-list [multiple]="true" placeholder="Food" [formControl]="control"
[required]="isRequired"
[tabIndex]="tabIndexOverride" [selectable]="selectable">
<md-chip *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
{{ food.viewValue }}
</md-chip>
</md-chip-list>
</md-form-field>
`
})
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<MdChip>;
}
@Component({
selector: 'input-chip-list',
template: `
<md-form-field>
<md-chip-list [multiple]="true"
placeholder="Food" [formControl]="control" [required]="isRequired" #chipList1>
<md-chip *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</md-chip>
</md-chip-list>
<input placeholder="New food..."
[mdChipInputFor]="chipList1"
[mdChipInputSeparatorKeyCodes]="separatorKeyCodes"
[mdChipInputAddOnBlur]="addOnBlur"
(mdChipInputTokenEnd)="add($event)" />/>
</md-form-field>
`
})
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<MdChip>;
}
@Component({
template: `
<md-form-field>
<md-chip-list [formControl]="control">
<md-chip *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-chip>
</md-chip-list>
</md-form-field>
`
})
class FalsyValueChipList {
foods: any[] = [
{ value: 0, viewValue: 'Steak' },
{ value: 1, viewValue: 'Pizza' },
];
control = new FormControl();
@ViewChildren(MdChip) chips: QueryList<MdChip>;
}
+514 -109
View File
@@ -5,27 +5,55 @@
* 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,
ChangeDetectionStrategy,
Component,
ContentChildren,
Input,
QueryList,
ViewEncapsulation,
OnDestroy,
Optional,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Optional,
Output,
QueryList,
Renderer2,
Self,
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 {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 {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge';
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {MdChip, MdChipEvent, MdChipSelectionChange} from './chip';
import {MdChipInput} from './chip-input';
// 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 +65,34 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
exportAs: 'mdChipList, matChipList',
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]': 'multiple',
'[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<any>, 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<void>();
/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
protected _lastDestroyedIndex: number|null = null;
@@ -62,10 +103,49 @@ 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 blur changes in the chips. */
private _chipBlurSubscription: 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;
/** Whether the component is in multiple selection mode. */
private _multiple: boolean = false;
/** 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 +159,169 @@ export class MdChipList implements AfterContentInit, OnDestroy {
/** The FocusKeyManager which handles focus. */
_keyManager: FocusKeyManager<MdChip>;
/** The chip components contained within this chip list. */
chips: QueryList<MdChip>;
/** Function when touched */
_onTouched = () => {};
/** Function when changed */
_onChange: (value: any) => void = () => {};
_selectionModel: SelectionModel<MdChip>;
/** 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[] | 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);
}
/**
* 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<MdChipSelectionChange> {
return merge(...this.chips.map(chip => chip.onSelectionChange));
}
/** Combined stream of all of the child chips' focus change events. */
get chipFocusChanges(): Observable<MdChipEvent> {
return merge(...this.chips.map(chip => chip._onFocus));
}
/** Combined stream of all of the child chips' blur change events. */
get chipBlurChanges(): Observable<MdChipEvent> {
return merge(...this.chips.map(chip => chip._onBlur));
}
/** Combined stream of all of the child chips' remove change events. */
get chipRemoveChanges(): Observable<MdChipEvent> {
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<MdChipListChange> = new EventEmitter<MdChipListChange>();
/**
* 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<any>();
/** The chip components contained within this chip list. */
@ContentChildren(MdChip) chips: QueryList<MdChip>;
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<MdChip>(this.chips).withWrap();
// Prevents the chip list from capturing focus and redirecting
@@ -99,20 +331,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<MdChip>) => {
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 +346,51 @@ export class MdChipList implements AfterContentInit, OnDestroy {
});
}
ngOnInit() {
this._selectionModel = new SelectionModel<MdChip>(this.multiple, 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;
}
set selectable(value: boolean) {
this._selectable = coerceBooleanProperty(value);
}
@Input()
set tabIndex(value: number) {
this._userTabIndex = value;
this._tabIndex = value;
}
/** Associates an HTML input element with this chip list. */
registerInput(inputElement: HTMLInputElement) {
this._inputElement = inputElement;
registerInput(inputElement: MdChipInput) {
this._chipInput = inputElement;
}
// 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);
}
}
// Implemented as part of ControlValueAccessor
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
}
// 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 +399,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 +450,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<MdChip>): void {
chips.forEach(chip => this._addChip(chip));
}
/**
* Check the tab index as you should not be allowed to focus an empty list.
@@ -223,51 +463,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;
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;
}
}
// 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);
}
}
if (this._keyManager.activeItemIndex === chipIndex) {
this._lastDestroyedIndex = chipIndex;
}
}
this._chipSet.delete(chip);
chip.destroy.unsubscribe();
});
this._chipSet.set(chip, true);
}
/**
@@ -282,11 +497,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 +528,193 @@ 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();
} 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));
}
}
}
/**
* 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 {
if (this._multiple) {
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(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);
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._chipBlurSubscription) {
this._chipBlurSubscription.unsubscribe();
this._chipBlurSubscription = 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);
// 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();
}
});
}
/** 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();
});
this._chipBlurSubscription = this.chipBlurChanges.subscribe(_ => {
this._blur();
this.stateChanges.next();
});
}
private _listenToChipsRemoved(): void {
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event) => {
this._updateKeyManager(event.chip);
});
}
}
+14 -15
View File
@@ -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', () => {
<md-chip [selectable]="selectable" [removable]="removable"
[color]="color" [selected]="selected" [disabled]="disabled"
(focus)="chipFocus($event)" (destroy)="chipDestroy($event)"
(select)="chipSelect($event)" (deselect)="chipDeselect($event)"
(onSelectionChange)="chipSelectionChange($event)"
(remove)="chipRemove($event)">
{{name}}
</md-chip>
@@ -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 = () => {};
}
+79 -44
View File
@@ -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,
@@ -34,6 +32,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 {
@@ -71,80 +75,106 @@ export class MdBasicChip { }
'(click)': '_handleClick($event)',
'(keydown)': '_handleKeydown($event)',
'(focus)': '_hasFocus = true',
'(blur)': '_hasFocus = false',
}
'(blur)': '_blur()',
},
})
export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestroy, CanColor,
CanDisable {
@ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove;
protected _value: any;
/** Whether the chip is 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});
}
protected _selected: boolean = false;
/**
* 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;
/**
* 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;
/** Whether the chip is selected. */
@Input()
get selected(): boolean { return this._selected; }
set selected(value: boolean) {
this._selected = coerceBooleanProperty(value);
this.onSelectionChange.emit({source: this, isUserInput: false});
}
/** The value of the chip. Defaults to the content inside <md-chip> tags. */
@Input()
get value(): any {
return this._value != undefined
? this._value
: this._elementRef.nativeElement.textContent;
}
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); }
/**
* 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); }
/** Emits when the chip is focused. */
_onFocus = new Subject<MdChipEvent>();
/** Emitted when the chip is selected. */
@Output() select = new EventEmitter<MdChipEvent>();
/** Emits when the chip is blured. */
_onBlur = new Subject<MdChipEvent>();
/** Emitted when the chip is deselected. */
@Output() deselect = new EventEmitter<MdChipEvent>();
/** Emitted when the chip is selected or deselected. */
@Output() onSelectionChange = new EventEmitter<MdChipSelectionChange>();
/** Emitted when the chip is destroyed. */
@Output() destroy = new EventEmitter<MdChipEvent>();
/** Emitted when a chip is to be removed. */
@Output('remove') onRemove = new EventEmitter<MdChipEvent>();
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<MdChipEvent>();
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 +226,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
@@ -204,6 +234,11 @@ export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestr
break;
}
}
_blur() {
this._hasFocus = false;
this._onBlur.next({chip: this});
}
}
+1
View File
@@ -39,6 +39,7 @@ An exmaple of chip input placed inside the chip-list element.
<!-- example(chips-input) -->
An example of chip input placed outside the chip-list element.
```html
<md-form-field>
<md-chip-list #chipList>
+12 -3
View File
@@ -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;
}
+19 -19
View File
@@ -69,13 +69,13 @@ let nextUniqueId = 0;
})
export class MdInput implements MdFormFieldControl<any>, 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<any>, OnChanges, OnDestroy, D
}
}
private _neverEmptyInputTypes = [
protected _neverEmptyInputTypes = [
'date',
'datetime',
'datetime-local',
@@ -146,12 +146,12 @@ export class MdInput implements MdFormFieldControl<any>, 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<any>, 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<any>, 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<any>, 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`.