feat(chips): Add removal functionality/styling. (#4912)

* feat(chips): Add remove functionality/styling.

Add events, styling and keyboard controls to allow removable chips.

 - Add basic styling for a user-provided remove icon.
 - Add keyboard controls for backspace/delete.
 - Add `(remove)` event which is emitted when the remove icon or
   one of the delete keys is pressed.
 - Add `md-chip-remove` directive which can be applied to `<md-icon>`
   (or others) to inform the chip of the remove request.

Add new directive `mdChipInput` for controlling:
 - `(chipAdded)` - Event fired when a chip should be added.
 - `[separatorKeys]` - The list of keycodes that will fire the
   `(chipAdded)` event.
 - `[addOnBlur]` - Whether or not to fire the `(chipAdded)` event
   when the input is blurred.

Additionally, fix some issues with dark theme and add styling/support
for usage inside the `md-input-container` and add styling for focused
chips.

BREAKING CHANGE - The `selectable` property of the `md-chip-list` has
now been moved to `md-chip` to maintain consistency with the new
`removable` option.

If you used the following code,

```html
<md-chip-list [selectable]="selectable">
  <md-chip>My Chip</md-chip>
</md-chip-list>
```

you should switch it to

```html
<md-chip-list>
  <md-chip [selectable]="selectable">My Chip</md-chip>
</md-chip-list>
```

References #120. Closes #3143.
This commit is contained in:
tinayuangao
2017-07-13 17:13:39 -07:00
committed by Jeremy Elbourn
parent 243b97dfb0
commit c82aca9f8d
14 changed files with 1148 additions and 326 deletions
+55 -7
View File
@@ -23,11 +23,14 @@
<md-chip-list selectable="false">
<md-chip color="accent" selected="true">Selected/Colored</md-chip>
<md-chip color="warn" selected="true" *ngIf="visible"
(destroy)="alert('chip destroyed')" (click)="toggleVisible()">
(destroy)="displayMessage('chip destroyed')" (remove)="toggleVisible()">
With Events
<md-icon mdChipRemove>cancel</md-icon>
</md-chip>
</md-chip-list>
<div>{{message}}</div>
</md-card-content>
</md-card>
@@ -37,16 +40,61 @@
<md-card-content>
<h4>Input Container</h4>
<md-chip-list>
<md-chip *ngFor="let person of people" [color]="color">
{{person.name}}
</md-chip>
</md-chip-list>
<p>
You can easily put the the <code>&lt;md-chip-list&gt;</code> inside of an
<code>&lt;md-input-container&gt;</code>.
</p>
<md-input-container>
<input mdInput #input (keyup.enter)="add(input)" placeholder="New Contributor..."/>
<md-chip-list mdPrefix #chipList1>
<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 mdInput placeholder="New Contributor..."
[mdChipInputFor]="chipList1"
[mdChipInputSeparatorKeyCodes]="separatorKeysCodes"
[mdChipInputAddOnBlur]="addOnBlur"
(mdChipInputTokenEnd)="add($event)" />
</md-input-container>
<p>
You can also put <code>&lt;md-input-container&gt;</code> outside of an <code>md-chip-list</code>.
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-input-container>
<input mdInput placeholder="New Contributor..."
[mdChipInputFor]="chipList2"
[mdChipInputSeparatorKeyCodes]="separatorKeysCodes"
[mdChipInputAddOnBlur]="addOnBlur"
(mdChipInputTokenEnd)="add($event)" />
</md-input-container>
<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.
</p>
<h4>Options</h4>
<p>
<md-checkbox name="selectable" [(ngModel)]="selectable">Selectable</md-checkbox>
<md-checkbox name="removable" [(ngModel)]="removable">Removable</md-checkbox>
<md-checkbox name="addOnBlur" [(ngModel)]="addOnBlur">Add on Blur</md-checkbox>
</p>
<h4>Stacked Chips</h4>
<p>
+13 -1
View File
@@ -20,4 +20,16 @@
.mat-basic-chip {
margin: auto 10px;
}
}
md-chip-list input {
width: 150px;
}
.mat-chip-remove.mat-icon {
font-size: 16px;
width: 1em;
height: 1em;
vertical-align: middle;
cursor: pointer;
}
}
+31 -5
View File
@@ -1,4 +1,7 @@
import {Component} from '@angular/core';
import {MdChipInputEvent, ENTER} from '@angular/material';
const COMMA = 188;
export interface Person {
name: string;
@@ -18,6 +21,13 @@ export interface DemoColor {
export class ChipsDemo {
visible: boolean = true;
color: string = '';
selectable: boolean = true;
removable: boolean = true;
addOnBlur: boolean = true;
message: string = '';
// Enter, comma, semi-colon
separatorKeysCodes = [ENTER, COMMA, 186];
people: Person[] = [
{ name: 'Kara' },
@@ -35,17 +45,33 @@ export class ChipsDemo {
{ name: 'Warn', color: 'warn' }
];
alert(message: string): void {
alert(message);
displayMessage(message: string): void {
this.message = message;
}
add(input: HTMLInputElement): void {
if (input.value && input.value.trim() != '') {
this.people.push({ name: input.value.trim() });
add(event: MdChipInputEvent): void {
let input = event.input;
let value = event.value;
// Add our person
if ((value || '').trim()) {
this.people.push({ name: value.trim() });
}
// Reset the input value
if (input) {
input.value = '';
}
}
remove(person: Person): void {
let index = this.people.indexOf(person);
if (index >= 0) {
this.people.splice(index, 1);
}
}
toggleVisible(): void {
this.visible = false;
}
+28 -16
View File
@@ -6,6 +6,23 @@
$mat-chip-font-size: 13px;
$mat-chip-line-height: 16px;
@mixin mat-chips-theme-color($color) {
@include mat-chips-color(mat-contrast($color, 500), mat-color($color, 500));
}
@mixin mat-chips-color($foreground, $background) {
background-color: $background;
color: $foreground;
.mat-chip-remove {
color: $foreground;
opacity: 0.4;
}
.mat-chip-remove:hover {
opacity: 0.54;
}
}
@mixin mat-chips-theme($theme) {
$is-dark-theme: map-get($theme, is-dark);
@@ -28,34 +45,29 @@ $mat-chip-line-height: 16px;
$selected-background: if($is-dark-theme, mat-color($background, app-bar), #808080);
$selected-foreground: if($is-dark-theme, mat-color($foreground, text), $light-selected-foreground);
.mat-chip:not(.mat-basic-chip) {
background-color: $unselected-background;
color: $unselected-foreground;
.mat-chip {
@include mat-chips-color($unselected-foreground, $unselected-background);
}
.mat-chip.mat-chip-selected:not(.mat-basic-chip) {
background-color: $selected-background;
color: $selected-foreground;
.mat-chip.mat-chip-selected {
@include mat-chips-color($selected-foreground, $selected-background);
&.mat-primary {
background-color: mat-color($primary);
color: mat-color($primary, default-contrast);
}
&.mat-accent {
background-color: mat-color($accent);
color: mat-color($accent, default-contrast);
@include mat-chips-theme-color($primary);
}
&.mat-warn {
background-color: mat-color($warn);
color: mat-color($warn, default-contrast);
@include mat-chips-theme-color($warn);
}
&.mat-accent {
@include mat-chips-theme-color($accent);
}
}
}
@mixin mat-chips-typography($config) {
.mat-chip:not(.mat-basic-chip) {
.mat-chip {
font-size: $mat-chip-font-size;
line-height: $mat-chip-line-height;
}
+120
View File
@@ -0,0 +1,120 @@
import {async, TestBed, ComponentFixture} from '@angular/core/testing';
import {MdChipsModule} from './index';
import {Component, DebugElement} from '@angular/core';
import {MdChipInput, MdChipInputEvent} from './chip-input';
import {By} from '@angular/platform-browser';
import {Directionality} from '../core';
import {createKeyboardEvent} from '@angular/cdk/testing';
import {ENTER} from '../core/keyboard/keycodes';
const COMMA = 188;
describe('MdChipInput', () => {
let fixture: ComponentFixture<any>;
let testChipInput: TestChipInput;
let inputDebugElement: DebugElement;
let inputNativeElement: HTMLElement;
let chipInputDirective: MdChipInput;
let dir = 'ltr';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdChipsModule],
declarations: [TestChipInput],
providers: [{
provide: Directionality, useFactory: () => {
return {value: dir.toLowerCase()};
}
}]
});
TestBed.compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(TestChipInput);
testChipInput = fixture.debugElement.componentInstance;
fixture.detectChanges();
inputDebugElement = fixture.debugElement.query(By.directive(MdChipInput));
chipInputDirective = inputDebugElement.injector.get(MdChipInput) as MdChipInput;
inputNativeElement = inputDebugElement.nativeElement;
}));
describe('basic behavior', () => {
it('emits the (chipEnd) on enter keyup', () => {
let ENTER_EVENT = createKeyboardEvent('keydown', ENTER, inputNativeElement) as any;
spyOn(testChipInput, 'add');
chipInputDirective._keydown(ENTER_EVENT);
expect(testChipInput.add).toHaveBeenCalled();
});
});
describe('[addOnBlur]', () => {
it('allows (chipEnd) when true', () => {
spyOn(testChipInput, 'add');
testChipInput.addOnBlur = true;
fixture.detectChanges();
chipInputDirective._blur();
expect(testChipInput.add).toHaveBeenCalled();
});
it('disallows (chipEnd) when false', () => {
spyOn(testChipInput, 'add');
testChipInput.addOnBlur = false;
fixture.detectChanges();
chipInputDirective._blur();
expect(testChipInput.add).not.toHaveBeenCalled();
});
});
describe('[separatorKeysCodes]', () => {
it('does not emit (chipEnd) when a non-separator key is pressed', () => {
let ENTER_EVENT = createKeyboardEvent('keydown', ENTER, inputNativeElement) as any;
spyOn(testChipInput, 'add');
testChipInput.separatorKeys = [COMMA];
fixture.detectChanges();
chipInputDirective._keydown(ENTER_EVENT);
expect(testChipInput.add).not.toHaveBeenCalled();
});
it('emits (chipEnd) when a custom separator keys is pressed', () => {
let COMMA_EVENT = createKeyboardEvent('keydown', COMMA, inputNativeElement) as any;
spyOn(testChipInput, 'add');
testChipInput.separatorKeys = [COMMA];
fixture.detectChanges();
chipInputDirective._keydown(COMMA_EVENT);
expect(testChipInput.add).toHaveBeenCalled();
});
});
});
@Component({
template: `
<md-chip-list #chipList>
</md-chip-list>
<input mdInput [mdChipInputFor]="chipList"
[mdChipInputAddOnBlur]="addOnBlur"
[mdChipInputSeparatorKeyCodes]="separatorKeys"
(mdChipInputTokenEnd)="add($event)" />
`
})
class TestChipInput {
addOnBlur: boolean = false;
separatorKeys: number[] = [ENTER];
add(_: MdChipInputEvent) {
}
}
+103
View File
@@ -0,0 +1,103 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* 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 {Directive, Output, EventEmitter, ElementRef, Input} from '@angular/core';
import {coerceBooleanProperty} from '@angular/cdk';
import {ENTER} from '../core/keyboard/keycodes';
import {MdChipList} from './chip-list';
export interface MdChipInputEvent {
input: HTMLInputElement;
value: string;
}
@Directive({
selector: 'input[mdChipInputFor], input[matChipInputFor]',
host: {
'class': 'mat-chip-input',
'(keydown)': '_keydown($event)',
'(blur)': '_blur()'
}
})
export class MdChipInput {
_chipList: MdChipList;
/** Register input for chip list */
@Input('mdChipInputFor')
set chipList(value: MdChipList) {
if (value) {
this._chipList = value;
this._chipList.registerInput(this._inputElement);
}
}
/**
* Whether or not the chipEnd event will be emitted when the input is blurred.
*/
@Input('mdChipInputAddOnBlur')
get addOnBlur() { return this._addOnBlur; }
set addOnBlur(value) { this._addOnBlur = coerceBooleanProperty(value); }
_addOnBlur: boolean = false;
/**
* The list of key codes that will trigger a chipEnd event.
*
* Defaults to `[ENTER]`.
*/
// TODO(tinayuangao): Support Set here
@Input('mdChipInputSeparatorKeyCodes') separatorKeyCodes: number[] = [ENTER];
/** Emitted when a chip is to be added. */
@Output('mdChipInputTokenEnd')
chipEnd = new EventEmitter<MdChipInputEvent>();
@Input('matChipInputFor')
set matChipList(value: MdChipList) { this.chipList = value; }
@Input('matChipInputAddOnBlur')
get matAddOnBlur() { return this._addOnBlur; }
set matAddOnBlur(value) { this.addOnBlur = value; }
@Input('matChipInputSeparatorKeyCodes')
get matSeparatorKeyCodes() { return this.separatorKeyCodes; }
set matSeparatorKeyCodes(v: number[]) { this.separatorKeyCodes = v; }
/** The native input element to which this directive is attached. */
protected _inputElement: HTMLInputElement;
constructor(protected _elementRef: ElementRef) {
this._inputElement = this._elementRef.nativeElement as HTMLInputElement;
}
/** Utility method to make host definition/tests more clear. */
_keydown(event?: KeyboardEvent) {
this._emitChipEnd(event);
}
/** Checks to see if the blur should emit the (chipEnd) event. */
_blur() {
if (this.addOnBlur) {
this._emitChipEnd();
}
}
/** Checks to see if the (chipEnd) event needs to be emitted. */
_emitChipEnd(event?: KeyboardEvent) {
if (!this._inputElement.value && !!event) {
this._chipList._keydown(event);
}
if (!event || this.separatorKeyCodes.indexOf(event.keyCode) > -1) {
this.chipEnd.emit({ input: this._inputElement, value: this._inputElement.value });
if (event) {
event.preventDefault();
}
}
}
}
+295 -193
View File
@@ -1,32 +1,292 @@
import {async, ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
import {Component, DebugElement, QueryList} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdChip, MdChipList, MdChipsModule} from './index';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {MdChipList, MdChipsModule} from './index';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {SPACE, LEFT_ARROW, RIGHT_ARROW, TAB} from '../core/keyboard/keycodes';
import {createKeyboardEvent} from '@angular/cdk/testing';
import {MdInputModule} from '../input/index';
import {LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE, TAB} from '../core/keyboard/keycodes';
import {Directionality} from '../core';
describe('MdChipList', () => {
let fixture: ComponentFixture<any>;
let chipListDebugElement: DebugElement;
let chipListNativeElement: HTMLElement;
let chipListInstance: MdChipList;
let testComponent: StaticChipList;
let chips: QueryList<MdChip>;
let testComponent: StandardChipList;
let chips: QueryList<any>;
let manager: FocusKeyManager;
let dir = 'ltr';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdChipsModule],
declarations: [StaticChipList]
imports: [MdChipsModule, MdInputModule, NoopAnimationsModule],
declarations: [
StandardChipList, InputContainerChipList
],
providers: [{
provide: Directionality, useFactory: () => {
return {value: dir.toLowerCase()};
}
}]
});
TestBed.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(StaticChipList);
describe('StandardChipList', () => {
describe('basic behaviors', () => {
beforeEach(async(() => {
setupStandardList();
}));
it('should add the `mat-chip-list` class', () => {
expect(chipListNativeElement.classList).toContain('mat-chip-list');
});
});
describe('focus behaviors', () => {
beforeEach(async(() => {
setupStandardList();
manager = chipListInstance._keyManager;
}));
it('should focus the first chip on focus', () => {
chipListInstance.focus();
fixture.detectChanges();
expect(manager.activeItemIndex).toBe(0);
});
it('should watch for chip focus', () => {
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
lastItem.focus();
fixture.detectChanges();
expect(manager.activeItemIndex).toBe(lastIndex);
});
it('should watch for chip focus', () => {
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
lastItem.focus();
fixture.detectChanges();
expect(manager.activeItemIndex).toBe(lastIndex);
});
describe('on chip destroy', () => {
it('should focus the next item', () => {
let array = chips.toArray();
let midItem = array[2];
// Focus the middle item
midItem.focus();
// Destroy the middle item
testComponent.remove = 2;
fixture.detectChanges();
// It focuses the 4th item (now at index 2)
expect(manager.activeItemIndex).toEqual(2);
});
it('should focus the previous item', () => {
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
// Focus the last item
lastItem.focus();
// Destroy the last item
testComponent.remove = lastIndex;
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(lastIndex - 1);
});
});
});
describe('keyboard behavior', () => {
describe('LTR (default)', () => {
beforeEach(async(() => {
dir = 'ltr';
setupStandardList();
manager = chipListInstance._keyManager;
}));
it('should focus previous item when press LEFT ARROW', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
let LEFT_EVENT = createKeyboardEvent('keydown', LEFT_ARROW, lastNativeChip);
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
// Focus the last item in the array
lastItem.focus();
expect(manager.activeItemIndex).toEqual(lastIndex);
// Press the LEFT arrow
chipListInstance._keydown(LEFT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(lastIndex - 1);
});
it('should focus next item when press RIGHT ARROW', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let firstNativeChip = nativeChips[0] as HTMLElement;
let RIGHT_EVENT: KeyboardEvent =
createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip);
let array = chips.toArray();
let firstItem = array[0];
// Focus the last item in the array
firstItem.focus();
expect(manager.activeItemIndex).toEqual(0);
// Press the RIGHT arrow
chipListInstance._keydown(RIGHT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(1);
});
});
describe('RTL', () => {
beforeEach(async(() => {
dir = 'rtl';
setupStandardList();
manager = chipListInstance._keyManager;
}));
it('should focus previous item when press RIGHT ARROW', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
let RIGHT_EVENT: KeyboardEvent =
createKeyboardEvent('keydown', RIGHT_ARROW, lastNativeChip);
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
// Focus the last item in the array
lastItem.focus();
expect(manager.activeItemIndex).toEqual(lastIndex);
// Press the RIGHT arrow
chipListInstance._keydown(RIGHT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(lastIndex - 1);
});
it('should focus next item when press LEFT ARROW', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let firstNativeChip = nativeChips[0] as HTMLElement;
let LEFT_EVENT: KeyboardEvent =
createKeyboardEvent('keydown', LEFT_ARROW, firstNativeChip);
let array = chips.toArray();
let firstItem = array[0];
// Focus the last item in the array
firstItem.focus();
expect(manager.activeItemIndex).toEqual(0);
// Press the LEFT arrow
chipListInstance._keydown(LEFT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(1);
});
it('should allow focus to escape when tabbing away', fakeAsync(() => {
chipListInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB));
expect(chipListInstance._tabIndex)
.toBe(-1, 'Expected tabIndex to be set to -1 temporarily.');
tick();
expect(chipListInstance._tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0');
}));
});
});
});
describe('InputContainerChipList', () => {
beforeEach(() => {
setupInputList();
});
describe('keyboard behavior', () => {
beforeEach(() => {
manager = chipListInstance._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();
chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList));
@@ -34,196 +294,24 @@ describe('MdChipList', () => {
chipListInstance = chipListDebugElement.componentInstance;
testComponent = fixture.debugElement.componentInstance;
chips = chipListInstance.chips;
});
}
describe('basic behaviors', () => {
it('adds the `md-chip-list` class', () => {
expect(chipListNativeElement.classList).toContain('mat-chip-list');
});
});
function setupInputList() {
fixture = TestBed.createComponent(InputContainerChipList);
fixture.detectChanges();
describe('focus behaviors', () => {
beforeEach(() => {
manager = chipListInstance._keyManager;
});
it('should focus the first chip on focus', () => {
chipListInstance.focus();
fixture.detectChanges();
expect(manager.activeItemIndex).toBe(0);
});
it('should watch for chip focus', () => {
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
lastItem.focus();
fixture.detectChanges();
expect(manager.activeItemIndex).toBe(lastIndex);
});
describe('on chip destroy', () => {
it('focuses the next item', () => {
let array = chips.toArray();
let midItem = array[2];
// Focus the middle item
midItem.focus();
// Destroy the middle item
testComponent.remove = 2;
fixture.detectChanges();
// It focuses the 4th item (now at index 2)
expect(manager.activeItemIndex).toEqual(2);
});
it('should focus the previous item', () => {
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
// Focus the last item by fake updating the _hasFocus state for unit tests.
lastItem._hasFocus = true;
// Destroy the last item
testComponent.remove = lastIndex;
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(lastIndex - 1);
});
});
});
describe('keyboard behavior', () => {
beforeEach(() => {
manager = chipListInstance._keyManager;
});
it('left arrow focuses previous item', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
let LEFT_EVENT = createKeyboardEvent('keydown', LEFT_ARROW, lastNativeChip);
let array = chips.toArray();
let lastIndex = array.length - 1;
let lastItem = array[lastIndex];
// Focus the last item in the array
lastItem.focus();
expect(manager.activeItemIndex).toEqual(lastIndex);
// Press the LEFT arrow
chipListInstance._keydown(LEFT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(lastIndex - 1);
});
it('right arrow focuses next item', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let firstNativeChip = nativeChips[0] as HTMLElement;
let RIGHT_EVENT = createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip);
let array = chips.toArray();
let firstItem = array[0];
// Focus the last item in the array
firstItem.focus();
expect(manager.activeItemIndex).toEqual(0);
// Press the RIGHT arrow
chipListInstance._keydown(RIGHT_EVENT);
fixture.detectChanges();
// It focuses the next-to-last item
expect(manager.activeItemIndex).toEqual(1);
});
describe('when selectable is true', () => {
beforeEach(() => {
testComponent.selectable = true;
fixture.detectChanges();
});
it('SPACE selects/deselects the currently focused chip', () => {
let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
let firstNativeChip = nativeChips[0] as HTMLElement;
let SPACE_EVENT = createKeyboardEvent('keydown', SPACE, firstNativeChip);
let firstChip: MdChip = chips.toArray()[0];
spyOn(testComponent, 'chipSelect');
spyOn(testComponent, 'chipDeselect');
// Make sure we have the first chip focused
chipListInstance.focus();
// Use the spacebar to select the chip
chipListInstance._keydown(SPACE_EVENT);
fixture.detectChanges();
expect(firstChip.selected).toBeTruthy();
expect(testComponent.chipSelect).toHaveBeenCalledTimes(1);
expect(testComponent.chipSelect).toHaveBeenCalledWith(0);
// Use the spacebar to deselect the chip
chipListInstance._keydown(SPACE_EVENT);
fixture.detectChanges();
expect(firstChip.selected).toBeFalsy();
expect(testComponent.chipDeselect).toHaveBeenCalledTimes(1);
expect(testComponent.chipDeselect).toHaveBeenCalledWith(0);
});
it('allow focus to escape when tabbing away', fakeAsync(() => {
chipListInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB));
expect(chipListInstance._tabIndex)
.toBe(-1, 'Expected tabIndex to be set to -1 temporarily.');
tick();
expect(chipListInstance._tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0');
}));
});
describe('when selectable is false', () => {
beforeEach(() => {
testComponent.selectable = false;
fixture.detectChanges();
});
it('SPACE ignores selection', () => {
let SPACE_EVENT = createKeyboardEvent('keydown', SPACE);
let firstChip: MdChip = chips.toArray()[0];
spyOn(testComponent, 'chipSelect');
// Make sure we have the first chip focused
chipListInstance.focus();
// Use the spacebar to attempt to select the chip
chipListInstance._keydown(SPACE_EVENT);
fixture.detectChanges();
expect(firstChip.selected).toBeFalsy();
expect(testComponent.chipSelect).not.toHaveBeenCalled();
});
});
});
chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList));
chipListNativeElement = chipListDebugElement.nativeElement;
chipListInstance = chipListDebugElement.componentInstance;
testComponent = fixture.debugElement.componentInstance;
chips = chipListInstance.chips;
}
});
@Component({
template: `
<md-chip-list [selectable]="selectable">
<md-chip-list>
<div *ngFor="let i of [0,1,2,3,4]">
<div *ngIf="remove != i">
<md-chip (select)="chipSelect(i)" (deselect)="chipDeselect(i)">
@@ -233,11 +321,25 @@ describe('MdChipList', () => {
</div>
</md-chip-list>`
})
class StaticChipList {
class StandardChipList {
name: string = 'Test';
selectable: boolean = true;
remove: number;
chipSelect: (index?: number) => void = () => {};
chipDeselect: (index?: number) => void = () => {};
}
@Component({
template: `
<md-input-container>
<md-chip-list #chipList>
<md-chip>Chip 1</md-chip>
<md-chip>Chip 1</md-chip>
<md-chip>Chip 1</md-chip>
</md-chip-list>
<input mdInput name="test" [mdChipInputFor]="chipList"/>
</md-input-container>
`
})
class InputContainerChipList {
}
+138 -64
View File
@@ -15,13 +15,16 @@ import {
QueryList,
ViewEncapsulation,
OnDestroy,
Optional,
ElementRef,
Renderer2,
} from '@angular/core';
import {MdChip} from './chip';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
import {BACKSPACE, DELETE, LEFT_ARROW, RIGHT_ARROW, UP_ARROW} from '../core/keyboard/keycodes';
import {coerceBooleanProperty, Directionality} from '@angular/cdk';
import {Subscription} from 'rxjs/Subscription';
import {coerceBooleanProperty} from '@angular/cdk';
/**
* A material design chips component (named ChipList for it's similarity to the List component).
@@ -38,12 +41,10 @@ import {coerceBooleanProperty} from '@angular/cdk';
selector: 'md-chip-list, mat-chip-list',
template: `<div class="mat-chip-list-wrapper"><ng-content></ng-content></div>`,
host: {
// Properties
'[attr.tabindex]': '_tabIndex',
'role': 'listbox',
'class': 'mat-chip-list',
// Events
'(focus)': 'focus()',
'(keydown)': '_keydown($event)'
},
@@ -56,8 +57,11 @@ import {coerceBooleanProperty} from '@angular/cdk';
})
export class MdChipList implements AfterContentInit, OnDestroy {
/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
protected _lastDestroyedIndex: number|null = null;
/** Track which chips we're listening to for focus/destruction. */
private _subscribed: WeakMap<MdChip, boolean> = new WeakMap();
protected _chipSet: WeakMap<MdChip, boolean> = new WeakMap();
/** Subscription to tabbing out from the chip list. */
private _tabOutSubscription: Subscription;
@@ -65,14 +69,20 @@ export class MdChipList implements AfterContentInit, OnDestroy {
/** Whether or not the chip is selectable. */
protected _selectable: boolean = true;
protected _inputElement: HTMLInputElement;
/** Tab index for the chip list. */
_tabIndex = 0;
/** The FocusKeyManager which handles focus. */
_keyManager: FocusKeyManager;
/** The chip components contained within this chip list. */
chips: QueryList<MdChip>;
/** Tab index for the chip list. */
_tabIndex = 0;
constructor(protected _renderer: Renderer2, protected _elementRef: ElementRef,
@Optional() private _dir: Directionality) {
}
ngAfterContentInit(): void {
this._keyManager = new FocusKeyManager(this.chips).withWrap();
@@ -87,9 +97,23 @@ export class MdChipList implements AfterContentInit, OnDestroy {
// 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);
// If we have 0 chips, attempt to focus an input (if available)
if (chips.length === 0) {
this._focusInput();
}
// Check to see if we need to update our tab index
this._updateTabIndex();
// Check to see if we have a destroyed chip and need to refocus
this._updateFocusForDestroyedChips();
});
}
@@ -104,64 +128,69 @@ export class MdChipList implements AfterContentInit, OnDestroy {
* it's selected state is always ignored.
*/
@Input()
get selectable(): boolean { return this._selectable; }
get selectable(): boolean {
return this._selectable;
}
set selectable(value: boolean) {
this._selectable = coerceBooleanProperty(value);
}
/**
* Programmatically focus the chip list. This in turn focuses the first
* non-disabled chip in this chip list.
*/
focus() {
// TODO: ARIA says this should focus the first `selected` chip.
this._keyManager.setFirstItemActive();
/** Associates an HTML input element with this chip list. */
registerInput(inputElement: HTMLInputElement) {
this._inputElement = inputElement;
}
/** Passes relevant key presses to our key manager. */
_keydown(event: KeyboardEvent) {
let target = event.target as HTMLElement;
// If they are on a chip, check for space/left/right, otherwise pass to our key manager
if (target && target.classList.contains('mat-chip')) {
switch (event.keyCode) {
case SPACE:
// If we are selectable, toggle the focused chip
if (this.selectable) {
this._toggleSelectOnFocusedChip();
}
// Always prevent space from scrolling the page since the list has focus
event.preventDefault();
break;
case LEFT_ARROW:
this._keyManager.setPreviousItemActive();
event.preventDefault();
break;
case RIGHT_ARROW:
this._keyManager.setNextItemActive();
event.preventDefault();
break;
default:
this._keyManager.onKeydown(event);
}
/**
* Focuses the the first non-disabled chip in this chip list, or the associated input when there
* are no eligible chips.
*/
focus() {
// TODO: ARIA says this should focus the first `selected` chip if any are selected.
if (this.chips.length > 0) {
this._keyManager.setFirstItemActive();
} else {
this._focusInput();
}
}
/** Toggles the selected state of the currently focused chip. */
protected _toggleSelectOnFocusedChip(): void {
// Allow disabling of chip selection
if (!this.selectable) {
/** Attempt to focus an input if we have one. */
_focusInput() {
if (this._inputElement) {
this._inputElement.focus();
}
}
/**
* Pass events to the keyboard manager. Available here for tests.
*/
_keydown(event: KeyboardEvent) {
let code = event.keyCode;
let target = event.target as HTMLElement;
let isInputEmpty = this._isInputEmpty(target);
let isRtl = this._dir && this._dir.value == 'rtl';
let isPrevKey = (code === (isRtl ? RIGHT_ARROW : LEFT_ARROW));
let isNextKey = (code === (isRtl ? LEFT_ARROW : RIGHT_ARROW));
let isBackKey = (code === BACKSPACE || code == DELETE || code == UP_ARROW || isPrevKey);
// If they are on an empty input and hit backspace/delete/left arrow, focus the last chip
if (isInputEmpty && isBackKey) {
this._keyManager.setLastItemActive();
event.preventDefault();
return;
}
let focusedIndex = this._keyManager.activeItemIndex;
if (typeof focusedIndex === 'number' && this._isValidIndex(focusedIndex)) {
let focusedChip: MdChip = this.chips.toArray()[focusedIndex];
if (focusedChip) {
focusedChip.toggleSelected();
// If they are on a chip, check for space/left/right, otherwise pass to our key manager (like
// up/down keys)
if (target && target.classList.contains('mat-chip')) {
if (isPrevKey) {
this._keyManager.setPreviousItemActive();
event.preventDefault();
} else if (isNextKey) {
this._keyManager.setNextItemActive();
event.preventDefault();
} else {
this._keyManager.onKeydown(event);
}
}
}
@@ -176,6 +205,14 @@ export class MdChipList implements AfterContentInit, OnDestroy {
chips.forEach(chip => this._addChip(chip));
}
/**
* Check the tab index as you should not be allowed to focus an empty list.
*/
protected _updateTabIndex(): void {
// If we have 0 chips, we should not allow keyboard focus
this._tabIndex = (this.chips.length === 0 ? -1 : 0);
}
/**
* Add a specific chip to our subscribed list. If the chip has
* already been subscribed, this ensures it is only subscribed
@@ -186,7 +223,7 @@ export class MdChipList implements AfterContentInit, OnDestroy {
*/
protected _addChip(chip: MdChip) {
// If we've already been subscribed to a parent, do nothing
if (this._subscribed.has(chip)) {
if (this._chipSet.has(chip)) {
return;
}
@@ -199,24 +236,52 @@ export class MdChipList implements AfterContentInit, OnDestroy {
}
});
// On destroy, remove the item from our list, and check focus
// 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) && 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._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._subscribed.delete(chip);
this._chipSet.delete(chip);
chip.destroy.unsubscribe();
});
this._subscribed.set(chip, true);
this._chipSet.set(chip, true);
}
/**
* Checks to see if a focus chip was recently destroyed so that we can refocus the next closest
* one.
*/
protected _updateFocusForDestroyedChips() {
let chipsArray = this.chips;
if (this._lastDestroyedIndex != null && chipsArray.length > 0) {
// Check whether the destroyed chip was the last item
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();
}
}
// Reset our destroyed index
this._lastDestroyedIndex = null;
}
/**
@@ -229,4 +294,13 @@ export class MdChipList implements AfterContentInit, OnDestroy {
return index >= 0 && index < this.chips.length;
}
private _isInputEmpty(element: HTMLElement): boolean {
if (element && element.nodeName.toLowerCase() === 'input') {
let input = element as HTMLInputElement;
return !input.value;
}
return false;
}
}
+63
View File
@@ -0,0 +1,63 @@
import {Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {MdChip, MdChipsModule} from './index';
describe('Chip Remove', () => {
let fixture: ComponentFixture<any>;
let testChip: TestChip;
let chipDebugElement: DebugElement;
let chipNativeElement: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdChipsModule],
declarations: [
TestChip
]
});
TestBed.compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(TestChip);
testChip = fixture.debugElement.componentInstance;
fixture.detectChanges();
chipDebugElement = fixture.debugElement.query(By.directive(MdChip));
chipNativeElement = chipDebugElement.nativeElement;
}));
describe('basic behavior', () => {
it('should applies the `mat-chip-remove` CSS class', () => {
let hrefElement = chipNativeElement.querySelector('a')!;
expect(hrefElement.classList).toContain('mat-chip-remove');
});
it('should emits (remove) on click', () => {
let hrefElement = chipNativeElement.querySelector('a')!;
testChip.removable = true;
fixture.detectChanges();
spyOn(testChip, 'didRemove');
hrefElement.click();
expect(testChip.didRemove).toHaveBeenCalled();
});
});
});
@Component({
template: `
<md-chip [removable]="removable" (remove)="didRemove()"><a mdChipRemove></a></md-chip>
`
})
class TestChip {
removable: boolean;
didRemove() {}
}
+161 -21
View File
@@ -1,7 +1,10 @@
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 {SPACE, DELETE, BACKSPACE} from '../core/keyboard/keycodes';
import {Directionality} from '../core';
describe('Chips', () => {
let fixture: ComponentFixture<any>;
@@ -10,12 +13,19 @@ describe('Chips', () => {
let chipNativeElement: HTMLElement;
let chipInstance: MdChip;
let dir = 'ltr';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdChipsModule],
declarations: [
BasicChip, SingleChip
]
],
providers: [{
provide: Directionality, useFactory: () => {
return {value: dir};
}
}]
});
TestBed.compileComponents();
@@ -47,25 +57,25 @@ describe('Chips', () => {
describe('MdChip', () => {
let testComponent: SingleChip;
beforeEach(() => {
fixture = TestBed.createComponent(SingleChip);
fixture.detectChanges();
chipDebugElement = fixture.debugElement.query(By.directive(MdChip));
chipListNativeElement = fixture.debugElement.query(By.directive(MdChipList)).nativeElement;
chipNativeElement = chipDebugElement.nativeElement;
chipInstance = chipDebugElement.injector.get(MdChip);
testComponent = fixture.debugElement.componentInstance;
document.body.appendChild(chipNativeElement);
});
afterEach(() => {
document.body.removeChild(chipNativeElement);
});
describe('basic behaviors', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SingleChip);
fixture.detectChanges();
chipDebugElement = fixture.debugElement.query(By.directive(MdChip));
chipListNativeElement = fixture.debugElement.query(By.directive(MdChipList)).nativeElement;
chipNativeElement = chipDebugElement.nativeElement;
chipInstance = chipDebugElement.injector.get(MdChip);
testComponent = fixture.debugElement.componentInstance;
document.body.appendChild(chipNativeElement);
});
afterEach(() => {
document.body.removeChild(chipNativeElement);
});
it('adds the `md-chip` class', () => {
expect(chipNativeElement.classList).toContain('mat-chip');
});
@@ -110,7 +120,132 @@ describe('Chips', () => {
fixture.detectChanges();
expect(chipNativeElement.classList).toContain('mat-chip-selected');
expect(testComponent.chipSelect).toHaveBeenCalledWith({ chip: chipInstance });
expect(testComponent.chipSelect).toHaveBeenCalledWith({chip: chipInstance});
});
it('allows removal', () => {
spyOn(testComponent, 'chipRemove');
chipInstance.remove();
fixture.detectChanges();
expect(testComponent.chipRemove).toHaveBeenCalledWith({chip: chipInstance});
});
});
describe('keyboard behavior', () => {
describe('when selectable is true', () => {
beforeEach(() => {
testComponent.selectable = true;
fixture.detectChanges();
});
it('should selects/deselects the currently focused chip on SPACE', () => {
const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent;
const CHIP_EVENT: MdChipEvent = {chip: chipInstance};
spyOn(testComponent, 'chipSelect');
spyOn(testComponent, 'chipDeselect');
// 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);
// 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);
});
});
describe('when selectable is false', () => {
beforeEach(() => {
testComponent.selectable = false;
fixture.detectChanges();
});
it('SPACE ignores selection', () => {
const SPACE_EVENT: KeyboardEvent = createKeyboardEvent('keydown', SPACE) as KeyboardEvent;
spyOn(testComponent, 'chipSelect');
// Use the spacebar to attempt to select the chip
chipInstance._handleKeydown(SPACE_EVENT);
fixture.detectChanges();
expect(chipInstance.selected).toBeFalsy();
expect(testComponent.chipSelect).not.toHaveBeenCalled();
});
});
describe('when removable is true', () => {
beforeEach(() => {
testComponent.removable = true;
fixture.detectChanges();
});
it('DELETE emits the (remove) event', () => {
const DELETE_EVENT = createKeyboardEvent('keydown', DELETE) as KeyboardEvent;
spyOn(testComponent, 'chipRemove');
// Use the delete to remove the chip
chipInstance._handleKeydown(DELETE_EVENT);
fixture.detectChanges();
expect(testComponent.chipRemove).toHaveBeenCalled();
});
it('BACKSPACE emits the (remove) event', () => {
const BACKSPACE_EVENT = createKeyboardEvent('keydown', BACKSPACE) as KeyboardEvent;
spyOn(testComponent, 'chipRemove');
// Use the delete to remove the chip
chipInstance._handleKeydown(BACKSPACE_EVENT);
fixture.detectChanges();
expect(testComponent.chipRemove).toHaveBeenCalled();
});
});
describe('when removable is false', () => {
beforeEach(() => {
testComponent.removable = false;
fixture.detectChanges();
});
it('DELETE does not emit the (remove) event', () => {
const DELETE_EVENT = createKeyboardEvent('keydown', DELETE) as KeyboardEvent;
spyOn(testComponent, 'chipRemove');
// Use the delete to remove the chip
chipInstance._handleKeydown(DELETE_EVENT);
fixture.detectChanges();
expect(testComponent.chipRemove).not.toHaveBeenCalled();
});
it('BACKSPACE does not emit the (remove) event', () => {
const BACKSPACE_EVENT = createKeyboardEvent('keydown', BACKSPACE) as KeyboardEvent;
spyOn(testComponent, 'chipRemove');
// Use the delete to remove the chip
chipInstance._handleKeydown(BACKSPACE_EVENT);
fixture.detectChanges();
expect(testComponent.chipRemove).not.toHaveBeenCalled();
});
});
it('should update the aria-label for disabled chips', () => {
@@ -130,9 +265,11 @@ describe('Chips', () => {
template: `
<md-chip-list>
<div *ngIf="shouldShow">
<md-chip [color]="color" [selected]="selected" [disabled]="disabled"
<md-chip [selectable]="selectable" [removable]="removable"
[color]="color" [selected]="selected" [disabled]="disabled"
(focus)="chipFocus($event)" (destroy)="chipDestroy($event)"
(select)="chipSelect($event)" (deselect)="chipDeselect($event)">
(select)="chipSelect($event)" (deselect)="chipDeselect($event)"
(remove)="chipRemove($event)">
{{name}}
</md-chip>
</div>
@@ -143,12 +280,15 @@ class SingleChip {
name: string = 'Test';
color: string = 'primary';
selected: boolean = false;
selectable: boolean = true;
removable: boolean = true;
shouldShow: boolean = true;
chipFocus: (event?: MdChipEvent) => void = () => {};
chipDestroy: (event?: MdChipEvent) => void = () => {};
chipSelect: (event?: MdChipEvent) => void = () => {};
chipDeselect: (event?: MdChipEvent) => void = () => {};
chipRemove: (event?: MdChipEvent) => void = () => {};
}
@Component({
+111 -8
View File
@@ -8,18 +8,21 @@
import {
Directive,
ContentChild,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
Renderer2,
forwardRef,
} from '@angular/core';
import {Focusable} from '../core/a11y/focus-key-manager';
import {coerceBooleanProperty} from '@angular/cdk';
import {CanColor, mixinColor} from '../core/common-behaviors/color';
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
import {SPACE, BACKSPACE, DELETE} from '../core/keyboard/keycodes';
export interface MdChipEvent {
chip: MdChip;
@@ -58,12 +61,15 @@ export class MdBasicChip { }
'[attr.disabled]': 'disabled || null',
'[attr.aria-disabled]': 'disabled.toString()',
'(click)': '_handleClick($event)',
'(keydown)': '_handleKeydown($event)',
'(focus)': '_hasFocus = true',
'(blur)': '_hasFocus = false',
}
})
export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, CanColor, CanDisable {
@ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove;
/** Whether the chip is selected. */
@Input() get selected(): boolean { return this._selected; }
set selected(value: boolean) {
@@ -72,6 +78,31 @@ export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, Ca
}
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;
@@ -91,14 +122,14 @@ export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, Ca
super(renderer, elementRef);
}
/** Emitted when a chip is to be removed. */
@Output('remove') onRemove = new EventEmitter<MdChipEvent>();
ngOnDestroy(): void {
this.destroy.emit({chip: this});
}
/**
* Toggles the current selected state of this chip.
* @return Whether the chip is selected.
*/
/** Toggles the current selected state of this chip. */
toggleSelected(): boolean {
this.selected = !this.selected;
return this.selected;
@@ -110,14 +141,86 @@ export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, Ca
this.onFocus.emit({chip: this});
}
/**
* Allows for programmatic removal of the chip. Called by the MdChipList when the DELETE or
* BACKSPACE keys are pressed.
*
* Informs any listeners of the removal request. Does not remove the chip from the DOM.
*/
remove(): void {
if (this.removable) {
this.onRemove.emit({chip: this});
}
}
/** Ensures events fire properly upon click. */
_handleClick(event: Event) {
// Check disabled
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
} else {
this.focus();
return;
}
event.preventDefault();
event.stopPropagation();
this.focus();
}
/** Handle custom key presses. */
_handleKeydown(event: KeyboardEvent) {
if (this.disabled) {
return;
}
switch (event.keyCode) {
case DELETE:
case BACKSPACE:
// If we are removable, remove the focused chip
this.remove();
// Always prevent so page navigation does not occur
event.preventDefault();
break;
case SPACE:
// If we are selectable, toggle the focused chip
if (this.selectable) {
this.toggleSelected();
}
// Always prevent space from scrolling the page since the list has focus
event.preventDefault();
break;
}
}
}
/**
* Applies proper (click) support and adds styling for use with the Material Design "cancel" icon
* available at https://material.io/icons/#ic_cancel.
*
* Example:
*
* <md-chip>
* <md-icon mdChipRemove>cancel</md-icon>
* </md-chip>
*
* You *may* use a custom icon, but you may need to override the `md-chip-remove` positioning styles
* to properly center the icon within the chip.
*/
@Directive({
selector: '[mdChipRemove], [matChipRemove]',
host: {
'class': 'mat-chip-remove',
'(click)': '_handleClick($event)'
}
})
export class MdChipRemove {
constructor(protected _parentChip: MdChip) {}
/** Calls the parent chip's public `remove()` method if applicable. */
_handleClick() {
if (this._parentChip.removable) {
this._parentChip.remove();
}
}
}
+19
View File
@@ -2,6 +2,7 @@
$mat-chip-vertical-padding: 8px;
$mat-chip-horizontal-padding: 12px;
$mat-chip-remove-margin: -4px;
$mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
@@ -24,6 +25,16 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
[dir='rtl'] & {
margin: 0 $mat-chips-chip-margin 0 0;
}
.mat-input-prefix & {
&:last-child {
margin-right: $mat-chips-chip-margin;
}
[dir='rtl'] &:last-child {
margin-left: $mat-chips-chip-margin;
}
}
}
@include cdk-high-contrast {
@@ -49,3 +60,11 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
}
}
}
.mat-input-prefix .mat-chip-list-wrapper {
margin-bottom: $mat-chip-vertical-padding;
}
.mat-chip-remove {
margin: 0 $mat-chip-remove-margin 0 0;
}
+10 -10
View File
@@ -8,16 +8,16 @@
import {NgModule} from '@angular/core';
import {MdChipList} from './chip-list';
import {MdChip, MdBasicChip} from './chip';
@NgModule({
imports: [],
exports: [MdChipList, MdChip, MdBasicChip],
declarations: [MdChipList, MdChip, MdBasicChip]
})
export class MdChipsModule {}
import {MdBasicChip, MdChip, MdChipRemove} from './chip';
import {MdChipInput} from './chip-input';
export * from './chip-list';
export * from './chip';
export * from './chip-input';
@NgModule({
imports: [],
exports: [MdChipList, MdChip, MdChipInput, MdChipRemove, MdChipRemove, MdBasicChip],
declarations: [MdChipList, MdChip, MdChipInput, MdChipRemove, MdChipRemove, MdBasicChip]
})
export class MdChipsModule {}
+1 -1
View File
@@ -511,7 +511,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterC
/**
* Throws an error if the container's input child was removed.
*/
private _validateInputChild() {
protected _validateInputChild() {
if (!this._mdInputChild) {
throw getMdInputContainerMissingMdInputError();
}