+
+ You can easily put the the <md-chip-list>
inside of an
+ <md-input-container>
.
+
+
+
+
+
+
+ {{person.name}}
+ cancel
+
+
+
+
+
+
+
+
+ You can also put <md-input-container>
outside of an md-chip-list
.
+ With mdChipInput
the input work with chip-list
+
+
+
+
{{person.name}}
+ cancel
-
-
+
+
+ The example above has overridden the [separatorKeys]
input to allow for
+ ENTER
, COMMA
and SEMI COLON
keys.
+
+
+ Options
+
+ Selectable
+ Removable
+ Add on Blur
+
+
Stacked Chips
diff --git a/src/demo-app/chips/chips-demo.scss b/src/demo-app/chips/chips-demo.scss
index c44263807d75..c35407c7202c 100644
--- a/src/demo-app/chips/chips-demo.scss
+++ b/src/demo-app/chips/chips-demo.scss
@@ -20,4 +20,16 @@
.mat-basic-chip {
margin: auto 10px;
}
-}
\ No newline at end of file
+
+ md-chip-list input {
+ width: 150px;
+ }
+
+ .mat-chip-remove.mat-icon {
+ font-size: 16px;
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+ cursor: pointer;
+ }
+}
diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts
index 07e007d5509f..d58217352256 100644
--- a/src/demo-app/chips/chips-demo.ts
+++ b/src/demo-app/chips/chips-demo.ts
@@ -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;
}
diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss
index b55f5d1dd7f2..4e4f1e959a1b 100644
--- a/src/lib/chips/_chips-theme.scss
+++ b/src/lib/chips/_chips-theme.scss
@@ -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);
+ @include mat-chips-theme-color($primary);
}
- &.mat-accent {
- background-color: mat-color($accent);
- color: mat-color($accent, default-contrast);
+ &.mat-warn {
+ @include mat-chips-theme-color($warn);
}
- &.mat-warn {
- background-color: mat-color($warn);
- color: mat-color($warn, default-contrast);
+ &.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;
}
diff --git a/src/lib/chips/chip-input.spec.ts b/src/lib/chips/chip-input.spec.ts
new file mode 100644
index 000000000000..513d7eaf5e5a
--- /dev/null
+++ b/src/lib/chips/chip-input.spec.ts
@@ -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;
+ 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: `
+
+
+
+ `
+})
+class TestChipInput {
+ addOnBlur: boolean = false;
+ separatorKeys: number[] = [ENTER];
+
+ add(_: MdChipInputEvent) {
+ }
+}
diff --git a/src/lib/chips/chip-input.ts b/src/lib/chips/chip-input.ts
new file mode 100644
index 000000000000..9cf217614e85
--- /dev/null
+++ b/src/lib/chips/chip-input.ts
@@ -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();
+
+ @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();
+ }
+ }
+ }
+}
diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts
index aa87a8807e0b..f3b91356d9c9 100644
--- a/src/lib/chips/chip-list.spec.ts
+++ b/src/lib/chips/chip-list.spec.ts
@@ -1,229 +1,317 @@
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;
let chipListDebugElement: DebugElement;
let chipListNativeElement: HTMLElement;
let chipListInstance: MdChipList;
- let testComponent: StaticChipList;
- let chips: QueryList;
+ let testComponent: StandardChipList;
+ let chips: QueryList;
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);
- fixture.detectChanges();
-
- chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList));
- chipListNativeElement = chipListDebugElement.nativeElement;
- 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');
- });
- });
-
- describe('focus behaviors', () => {
- beforeEach(() => {
- manager = chipListInstance._keyManager;
- });
-
- it('should focus the first chip on focus', () => {
- chipListInstance.focus();
- fixture.detectChanges();
+ describe('StandardChipList', () => {
+ describe('basic behaviors', () => {
+ beforeEach(async(() => {
+ setupStandardList();
+ }));
- expect(manager.activeItemIndex).toBe(0);
+ it('should add the `mat-chip-list` class', () => {
+ expect(chipListNativeElement.classList).toContain('mat-chip-list');
+ });
});
- it('should watch for chip focus', () => {
- let array = chips.toArray();
- let lastIndex = array.length - 1;
- let lastItem = array[lastIndex];
+ describe('focus behaviors', () => {
+ beforeEach(async(() => {
+ setupStandardList();
+ manager = chipListInstance._keyManager;
+ }));
- lastItem.focus();
- fixture.detectChanges();
+ it('should focus the first chip on focus', () => {
+ chipListInstance.focus();
+ fixture.detectChanges();
- expect(manager.activeItemIndex).toBe(lastIndex);
- });
+ expect(manager.activeItemIndex).toBe(0);
+ });
- describe('on chip destroy', () => {
- it('focuses the next item', () => {
+ it('should watch for chip focus', () => {
let array = chips.toArray();
- let midItem = array[2];
-
- // Focus the middle item
- midItem.focus();
-
- // Destroy the middle item
- testComponent.remove = 2;
+ let lastIndex = array.length - 1;
+ let lastItem = array[lastIndex];
+ lastItem.focus();
fixture.detectChanges();
- // It focuses the 4th item (now at index 2)
- expect(manager.activeItemIndex).toEqual(2);
+ expect(manager.activeItemIndex).toBe(lastIndex);
});
- it('should focus the previous item', () => {
+ it('should watch for chip focus', () => {
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;
+ lastItem.focus();
fixture.detectChanges();
- // It focuses the next-to-last item
- expect(manager.activeItemIndex).toEqual(lastIndex - 1);
+ expect(manager.activeItemIndex).toBe(lastIndex);
});
- });
- });
- describe('keyboard behavior', () => {
- beforeEach(() => {
- manager = chipListInstance._keyManager;
- });
+ 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('left arrow focuses previous item', () => {
- let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
- let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
+ // It focuses the 4th item (now at index 2)
+ expect(manager.activeItemIndex).toEqual(2);
+ });
- 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);
+ it('should focus the previous item', () => {
+ let array = chips.toArray();
+ let lastIndex = array.length - 1;
+ let lastItem = array[lastIndex];
- // Press the LEFT arrow
- chipListInstance._keydown(LEFT_EVENT);
- fixture.detectChanges();
+ // Focus the last item
+ lastItem.focus();
- // It focuses the next-to-last item
- expect(manager.activeItemIndex).toEqual(lastIndex - 1);
+ // Destroy the last item
+ testComponent.remove = lastIndex;
+ 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;
+ describe('keyboard behavior', () => {
+ describe('LTR (default)', () => {
+ beforeEach(async(() => {
+ dir = 'ltr';
+ setupStandardList();
+ manager = chipListInstance._keyManager;
+ }));
- let RIGHT_EVENT = createKeyboardEvent('keydown', RIGHT_ARROW, firstNativeChip);
- let array = chips.toArray();
- let firstItem = array[0];
+ it('should focus previous item when press LEFT ARROW', () => {
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
- // Focus the last item in the array
- firstItem.focus();
- expect(manager.activeItemIndex).toEqual(0);
+ let LEFT_EVENT = createKeyboardEvent('keydown', LEFT_ARROW, lastNativeChip);
+ let array = chips.toArray();
+ let lastIndex = array.length - 1;
+ let lastItem = array[lastIndex];
- // Press the RIGHT arrow
- chipListInstance._keydown(RIGHT_EVENT);
- fixture.detectChanges();
+ // Focus the last item in the array
+ lastItem.focus();
+ expect(manager.activeItemIndex).toEqual(lastIndex);
- // It focuses the next-to-last item
- expect(manager.activeItemIndex).toEqual(1);
- });
+ // 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('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;
+ describe('RTL', () => {
+ beforeEach(async(() => {
+ dir = 'rtl';
+ setupStandardList();
+ manager = chipListInstance._keyManager;
+ }));
- let SPACE_EVENT = createKeyboardEvent('keydown', SPACE, firstNativeChip);
- let firstChip: MdChip = chips.toArray()[0];
+ it('should focus previous item when press RIGHT ARROW', () => {
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
- spyOn(testComponent, 'chipSelect');
- spyOn(testComponent, 'chipDeselect');
+ let RIGHT_EVENT: KeyboardEvent =
+ createKeyboardEvent('keydown', RIGHT_ARROW, lastNativeChip);
+ let array = chips.toArray();
+ let lastIndex = array.length - 1;
+ let lastItem = array[lastIndex];
- // Make sure we have the first chip focused
- chipListInstance.focus();
+ // Focus the last item in the array
+ lastItem.focus();
+ expect(manager.activeItemIndex).toEqual(lastIndex);
- // Use the spacebar to select the chip
- chipListInstance._keydown(SPACE_EVENT);
- fixture.detectChanges();
+ // Press the RIGHT arrow
+ chipListInstance._keydown(RIGHT_EVENT);
+ fixture.detectChanges();
- expect(firstChip.selected).toBeTruthy();
- expect(testComponent.chipSelect).toHaveBeenCalledTimes(1);
- expect(testComponent.chipSelect).toHaveBeenCalledWith(0);
+ // It focuses the next-to-last item
+ expect(manager.activeItemIndex).toEqual(lastIndex - 1);
+ });
- // Use the spacebar to deselect the chip
- chipListInstance._keydown(SPACE_EVENT);
- fixture.detectChanges();
+ it('should focus next item when press LEFT ARROW', () => {
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let firstNativeChip = nativeChips[0] as HTMLElement;
- expect(firstChip.selected).toBeFalsy();
- expect(testComponent.chipDeselect).toHaveBeenCalledTimes(1);
- expect(testComponent.chipDeselect).toHaveBeenCalledWith(0);
- });
+ 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('allow focus to escape when tabbing away', fakeAsync(() => {
- chipListInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB));
+ // It focuses the next-to-last item
+ expect(manager.activeItemIndex).toEqual(1);
+ });
- expect(chipListInstance._tabIndex)
+ 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();
+ tick();
- expect(chipListInstance._tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0');
- }));
+ expect(chipListInstance._tabIndex).toBe(0, 'Expected tabIndex to be reset back to 0');
+ }));
+ });
+ });
+ });
+
+ describe('InputContainerChipList', () => {
+
+ beforeEach(() => {
+ setupInputList();
});
- describe('when selectable is false', () => {
+ describe('keyboard behavior', () => {
beforeEach(() => {
- testComponent.selectable = false;
- fixture.detectChanges();
+ manager = chipListInstance._keyManager;
});
- it('SPACE ignores selection', () => {
- let SPACE_EVENT = createKeyboardEvent('keydown', SPACE);
- let firstChip: MdChip = chips.toArray()[0];
+ describe('when the input has focus', () => {
- spyOn(testComponent, 'chipSelect');
+ it('should focus the last chip when press DELETE', () => {
+ let nativeInput = fixture.nativeElement.querySelector('input');
+ let DELETE_EVENT: KeyboardEvent =
+ createKeyboardEvent('keydown', DELETE, nativeInput);
- // Make sure we have the first chip focused
- chipListInstance.focus();
+ // Focus the input
+ nativeInput.focus();
+ expect(manager.activeItemIndex).toBe(-1);
- // Use the spacebar to attempt to select the chip
- chipListInstance._keydown(SPACE_EVENT);
- fixture.detectChanges();
+ // 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);
+ });
- expect(firstChip.selected).toBeFalsy();
- expect(testComponent.chipSelect).not.toHaveBeenCalled();
});
});
});
+ function setupStandardList() {
+ fixture = TestBed.createComponent(StandardChipList);
+ fixture.detectChanges();
+
+ chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList));
+ chipListNativeElement = chipListDebugElement.nativeElement;
+ chipListInstance = chipListDebugElement.componentInstance;
+ testComponent = fixture.debugElement.componentInstance;
+ chips = chipListInstance.chips;
+ }
+
+ function setupInputList() {
+ fixture = TestBed.createComponent(InputContainerChipList);
+ fixture.detectChanges();
+
+ chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList));
+ chipListNativeElement = chipListDebugElement.nativeElement;
+ chipListInstance = chipListDebugElement.componentInstance;
+ testComponent = fixture.debugElement.componentInstance;
+ chips = chipListInstance.chips;
+ }
+
});
@Component({
template: `
-
+
@@ -233,11 +321,25 @@ describe('MdChipList', () => {
`
})
-class StaticChipList {
+class StandardChipList {
name: string = 'Test';
selectable: boolean = true;
remove: number;
-
chipSelect: (index?: number) => void = () => {};
chipDeselect: (index?: number) => void = () => {};
}
+
+@Component({
+ template: `
+
+
+ Chip 1
+ Chip 1
+ Chip 1
+
+
+
+ `
+})
+class InputContainerChipList {
+}
diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts
index 74ec27dbd7f7..feb65ebcf7a0 100644
--- a/src/lib/chips/chip-list.ts
+++ b/src/lib/chips/chip-list.ts
@@ -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: `
`,
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
= new WeakMap();
+ protected _chipSet: WeakMap = 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;
- /** 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) => {
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);
}
+ /** Associates an HTML input element with this chip list. */
+ registerInput(inputElement: HTMLInputElement) {
+ this._inputElement = inputElement;
+ }
+
/**
- * Programmatically focus the chip list. This in turn focuses the first
- * non-disabled chip in this chip list.
+ * 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.
- this._keyManager.setFirstItemActive();
+ // 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();
+ }
}
- /** 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);
- }
+ /** Attempt to focus an input if we have one. */
+ _focusInput() {
+ if (this._inputElement) {
+ this._inputElement.focus();
}
}
- /** Toggles the selected state of the currently focused chip. */
- protected _toggleSelectOnFocusedChip(): void {
- // Allow disabling of chip selection
- if (!this.selectable) {
+ /**
+ * 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;
+ }
}
diff --git a/src/lib/chips/chip-remove.spec.ts b/src/lib/chips/chip-remove.spec.ts
new file mode 100644
index 000000000000..39450a639438
--- /dev/null
+++ b/src/lib/chips/chip-remove.spec.ts
@@ -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;
+ 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: `
+
+ `
+})
+class TestChip {
+ removable: boolean;
+
+ didRemove() {}
+}
diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts
index 7f3e4ec90ae6..cbf45dc3df7c 100644
--- a/src/lib/chips/chip.spec.ts
+++ b/src/lib/chips/chip.spec.ts
@@ -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;
@@ -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,24 +57,24 @@ describe('Chips', () => {
describe('MdChip', () => {
let testComponent: SingleChip;
- describe('basic behaviors', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SingleChip);
+ fixture.detectChanges();
- 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;
- 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);
+ });
- document.body.appendChild(chipNativeElement);
- });
+ afterEach(() => {
+ document.body.removeChild(chipNativeElement);
+ });
- afterEach(() => {
- document.body.removeChild(chipNativeElement);
- });
+ describe('basic behaviors', () => {
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: `
-
+ (select)="chipSelect($event)" (deselect)="chipDeselect($event)"
+ (remove)="chipRemove($event)">
{{name}}
@@ -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({
diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts
index d3c55538986d..f8a5b1c7a954 100644
--- a/src/lib/chips/chip.ts
+++ b/src/lib/chips/chip.ts
@@ -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();
+
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:
+ *
+ *
+ * cancel
+ *
+ *
+ * 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();
}
}
}
diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss
index 218a5cfb27e1..314e0223fd2c 100644
--- a/src/lib/chips/chips.scss
+++ b/src/lib/chips/chips.scss
@@ -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;
+}
diff --git a/src/lib/chips/index.ts b/src/lib/chips/index.ts
index d4e2dbde8dd6..7a3485835088 100644
--- a/src/lib/chips/index.ts
+++ b/src/lib/chips/index.ts
@@ -8,16 +8,16 @@
import {NgModule} from '@angular/core';
import {MdChipList} from './chip-list';
-import {MdChip, MdBasicChip} from './chip';
+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, MdBasicChip],
- declarations: [MdChipList, MdChip, MdBasicChip]
+ exports: [MdChipList, MdChip, MdChipInput, MdChipRemove, MdChipRemove, MdBasicChip],
+ declarations: [MdChipList, MdChip, MdChipInput, MdChipRemove, MdChipRemove, MdBasicChip]
})
export class MdChipsModule {}
-
-
-export * from './chip-list';
-export * from './chip';
diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts
index 07fea22ced03..41b30c1cd089 100644
--- a/src/lib/input/input-container.ts
+++ b/src/lib/input/input-container.ts
@@ -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();
}