diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html
index 859d638695f6..46365832a4ed 100644
--- a/src/demo-app/chips/chips-demo.html
+++ b/src/demo-app/chips/chips-demo.html
@@ -23,8 +23,10 @@
Input Container
-
-
- {{person.name}}
-
-
+
+ You can easily put the the <md-chip-list>
inside of an
+ <md-input-container>
.
+
+
+ 0 ? 'always' : 'auto'">
+
+
+ {{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..80a953ed8beb 100644
--- a/src/demo-app/chips/chips-demo.scss
+++ b/src/demo-app/chips/chips-demo.scss
@@ -20,4 +20,8 @@
.mat-basic-chip {
margin: auto 10px;
}
+
+ md-chip-list input {
+ width: 150px;
+ }
}
\ No newline at end of file
diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts
index 90593475db14..2b68de3b771c 100644
--- a/src/demo-app/chips/chips-demo.ts
+++ b/src/demo-app/chips/chips-demo.ts
@@ -1,4 +1,5 @@
-import {Component, ElementRef} from '@angular/core';
+import {Component} from '@angular/core';
+import {MdChipInputEvent, ENTER, COMMA} from '@angular/material';
export interface Person {
name: string;
@@ -18,6 +19,12 @@ export interface DemoColor {
export class ChipsDemo {
visible: boolean = true;
color: string = '';
+ selectable: boolean = true;
+ removable: boolean = true;
+ addOnBlur: boolean = true;
+
+ // Enter, comma, semi-colon
+ separatorKeys = [ENTER, COMMA, 186];
people: Person[] = [
{ name: 'Kara' },
@@ -39,10 +46,26 @@ export class ChipsDemo {
alert(message);
}
- add(input: ElementRef): void {
- if (input.nativeElement.value && input.nativeElement.value.trim() != '') {
- this.people.push({ name: input.nativeElement.value.trim() });
- input.nativeElement.value = '';
+ add(event: MdChipInputEvent): void {
+ let input = event.input;
+ let value = event.value;
+
+ // Add our person
+ if (value && 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);
}
}
diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss
index 42dc3cdbdb3f..99883a975beb 100644
--- a/src/lib/chips/_chips-theme.scss
+++ b/src/lib/chips/_chips-theme.scss
@@ -16,34 +16,90 @@
// The spec only provides guidance for light-themed chips. When inside of a dark theme, fall back
// to standard background and foreground colors.
- $unselected-background: if($is-dark-theme, mat-color($background, card), #e0e0e0);
+ $unselected-background: if($is-dark-theme, #656565, #e0e0e0);
$unselected-foreground: if($is-dark-theme, mat-color($foreground, text), $light-foreground);
$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);
+ $focus-color: mat-color($foreground, secondary-text);
+
.mat-chip:not(.mat-basic-chip) {
background-color: $unselected-background;
color: $unselected-foreground;
+
+ .mat-chip-focus-border {
+ pointer-events: none;
+ }
+
+ &:focus {
+ outline: none;
+ border: 2px solid $focus-color;
+ }
+
+ .mat-chip-remove {
+ color: $unselected-foreground;
+ opacity: 0.3;
+
+ &:hover {
+ opacity: 0.54;
+ }
+ }
}
.mat-chip.mat-chip-selected:not(.mat-basic-chip) {
background-color: $selected-background;
color: $selected-foreground;
+ .mat-chip-remove {
+ color: $selected-foreground;
+ opacity: 0.4;
+
+ &:hover {
+ opacity: 0.54;
+ }
+ }
+
&.mat-primary {
background-color: mat-color($primary, 500);
color: mat-contrast($primary, 500);
+
+ .mat-chip-remove {
+ color: mat-contrast($primary, 500);
+ opacity: 0.4;
+
+ &:hover {
+ opacity: 0.54;
+ }
+ }
}
&.mat-accent {
background-color: mat-color($accent, 500);
color: mat-contrast($accent, 500);
+
+ .mat-chip-remove {
+ color: mat-contrast($accent, 500);
+ opacity: 0.4;
+
+ &:hover {
+ opacity: 0.54;
+ }
+ }
}
&.mat-warn {
background-color: mat-color($warn, 500);
color: mat-contrast($warn, 500);
+
+ .mat-chip-remove {
+ color: mat-contrast($warn, 500);
+ opacity: 0.4;
+
+ &:hover {
+ opacity: 0.54;
+ }
+ }
}
}
}
diff --git a/src/lib/chips/chip-input.spec.ts b/src/lib/chips/chip-input.spec.ts
new file mode 100644
index 000000000000..78549d4fe608
--- /dev/null
+++ b/src/lib/chips/chip-input.spec.ts
@@ -0,0 +1,115 @@
+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 {Dir} from '../core/rtl/dir';
+import {FakeKeyboardEvent} from './chip-list.spec';
+import {ENTER, COMMA} from '../core/keyboard/keycodes';
+
+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: Dir, 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 (chipAdded) on enter keyup', () => {
+ let ENTER_EVENT = new FakeKeyboardEvent(ENTER, inputNativeElement) as any;
+
+ spyOn(testChipInput, 'add');
+
+ chipInputDirective._keydown(ENTER_EVENT);
+ expect(testChipInput.add).toHaveBeenCalled();
+ });
+ });
+
+ describe('[addOnBlur]', () => {
+ it('allows (chipAdded) when true', () => {
+ spyOn(testChipInput, 'add');
+
+ testChipInput.addOnBlur = true;
+ fixture.detectChanges();
+
+ chipInputDirective._blur();
+ expect(testChipInput.add).toHaveBeenCalled();
+ });
+
+ it('disallows (chipAdded) when false', () => {
+ spyOn(testChipInput, 'add');
+
+ testChipInput.addOnBlur = false;
+ fixture.detectChanges();
+
+ chipInputDirective._blur();
+ expect(testChipInput.add).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('[separatorKeys]', () => {
+ it('does not emit (chipAdded) when a non-separator key is pressed', () => {
+ let ENTER_EVENT = new FakeKeyboardEvent(ENTER, inputNativeElement) as any;
+ spyOn(testChipInput, 'add');
+
+ testChipInput.separatorKeys = [COMMA];
+ fixture.detectChanges();
+
+ chipInputDirective._keydown(ENTER_EVENT);
+ expect(testChipInput.add).not.toHaveBeenCalled();
+ });
+
+ it('emits (chipAdded) when a custom separator keys is pressed', () => {
+ let COMMA_EVENT = new FakeKeyboardEvent(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(event: MdChipInputEvent) {
+ }
+}
diff --git a/src/lib/chips/chip-input.ts b/src/lib/chips/chip-input.ts
new file mode 100644
index 000000000000..9e4e30de9fd2
--- /dev/null
+++ b/src/lib/chips/chip-input.ts
@@ -0,0 +1,76 @@
+import {Directive, Output, EventEmitter, Renderer, ElementRef, Input} from '@angular/core';
+import {ENTER} from '../core/keyboard/keycodes';
+
+export interface MdChipInputEvent {
+ input: HTMLInputElement;
+ value: string;
+}
+
+@Directive({
+ selector: '[mdChipInput], [matChipInput]',
+ host: {
+ '(keydown)': '_keydown($event)',
+ '(blur)': '_blur()'
+ }
+})
+export class MdChipInput {
+
+ /**
+ * Whether or not the chipAdded event will be emitted when the input is blurred.
+ *
+ * Default `false`.
+ */
+ @Input() addOnBlur = false;
+
+ /**
+ * The list of key codes that will trigger a chipAdded event.
+ *
+ * Defaults to `[ENTER]`.
+ */
+ @Input() separatorKeys: number[] = [ENTER];
+
+ /** Emitted when a chip is to be added. */
+ @Output() chipAdded = new EventEmitter();
+
+ /** The native input element to which this directive is attached. */
+ protected _inputElement: HTMLInputElement;
+
+ constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {
+ this._inputElement = this._elementRef.nativeElement as HTMLInputElement;
+ }
+
+ /**
+ * Utility method to make host definition/tests more clear.
+ *
+ * @private
+ */
+ _keydown(event?: KeyboardEvent) {
+ this._add(event);
+ }
+
+ /**
+ * Checks to see if the blur should emit the (chipAdded) event.
+ *
+ * @private
+ */
+ _blur() {
+ if (this.addOnBlur) {
+ this._add();
+ }
+ }
+
+ /**
+ * Checks to see if the (chipAdded) event needs to be emitted.
+ *
+ * @private
+ */
+ _add(event?: KeyboardEvent) {
+ if (!event || this.separatorKeys.indexOf(event.keyCode) > -1) {
+ this.chipAdded.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 8651ab41eb2d..d87af6030fb7 100644
--- a/src/lib/chips/chip-list.spec.ts
+++ b/src/lib/chips/chip-list.spec.ts
@@ -3,10 +3,12 @@ import {Component, DebugElement, QueryList} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdChip, MdChipList, MdChipsModule} from './index';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
+import {MdInputModule} from '../input/index';
import {FakeEvent} from '../core/a11y/list-key-manager.spec';
-import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
+import {LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE} from '../core/keyboard/keycodes';
+import {Dir} from '../core/rtl/dir';
-class FakeKeyboardEvent extends FakeEvent {
+export class FakeKeyboardEvent extends FakeEvent {
constructor(keyCode: number, protected target: HTMLElement) {
super(keyCode);
@@ -19,209 +21,288 @@ describe('MdChipList', () => {
let chipListDebugElement: DebugElement;
let chipListNativeElement: HTMLElement;
let chipListInstance: MdChipList;
- let testComponent: StaticChipList;
+ let testComponent: StandardChipList;
let chips: QueryList;
let manager: FocusKeyManager;
+ let dir = 'ltr';
+
beforeEach(async(() => {
TestBed.configureTestingModule({
- imports: [MdChipsModule.forRoot()],
+ imports: [MdChipsModule, MdInputModule],
declarations: [
- StaticChipList
- ]
+ StandardChipList, InputContainerChipList
+ ],
+ providers: [{
+ provide: Dir, useFactory: () => {
+ return {value: dir.toLowerCase()};
+ }
+ }]
});
TestBed.compileComponents();
}));
- beforeEach(() => {
- fixture = TestBed.createComponent(StaticChipList);
- fixture.detectChanges();
+ describe('StandardChipList', () => {
- 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('basic behaviors', () => {
+ beforeEach(async(() => {
+ setupStandardList();
+ }));
- describe('focus behaviors', () => {
- beforeEach(() => {
- manager = chipListInstance._keyManager;
- });
-
- it('focuses the first chip on focus', () => {
- chipListInstance.focus();
- fixture.detectChanges();
-
- expect(manager.activeItemIndex).toBe(0);
+ it('adds the `mat-chip-list` class', () => {
+ expect(chipListNativeElement.classList).toContain('mat-chip-list');
+ });
});
- it('watches for chip focus', () => {
- let array = chips.toArray();
- let lastIndex = array.length - 1;
- let lastItem = array[lastIndex];
+ describe('focus behaviors', () => {
+ beforeEach(async(() => {
+ setupStandardList();
+ }));
- 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];
+ beforeEach(() => {
+ manager = chipListInstance._keyManager;
+ });
- // Focus the middle item
- midItem.focus();
+ it('focuses the first chip on focus', () => {
+ let FOCUS_EVENT = {} as Event;
- // Destroy the middle item
- testComponent.remove = 2;
+ chipListInstance.focus(FOCUS_EVENT);
fixture.detectChanges();
- // It focuses the 4th item (now at index 2)
- expect(manager.activeItemIndex).toEqual(2);
+ expect(manager.activeItemIndex).toBe(0);
});
- it('focuses the previous item', () => {
+ it('watches for chip focus', () => {
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);
+ expect(manager.activeItemIndex).toBe(lastIndex);
});
- });
- });
- describe('keyboard behavior', () => {
- beforeEach(() => {
- manager = chipListInstance._keyManager;
- });
+ 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('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 = new FakeKeyboardEvent(LEFT_ARROW, lastNativeChip) as any;
- let array = chips.toArray();
- let lastIndex = array.length - 1;
- let lastItem = array[lastIndex];
+ it('focuses the previous item', () => {
+ 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);
+ // Focus the last item
+ lastItem.focus();
- // Press the LEFT arrow
- chipListInstance._keydown(LEFT_EVENT);
- fixture.detectChanges();
+ // Destroy the last item
+ testComponent.remove = lastIndex;
+ fixture.detectChanges();
- // It focuses the next-to-last item
- expect(manager.activeItemIndex).toEqual(lastIndex - 1);
+ // 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: KeyboardEvent = new FakeKeyboardEvent(RIGHT_ARROW, firstNativeChip) as any;
- let array = chips.toArray();
- let firstItem = array[0];
+ it('LEFT ARROW focuses previous item', () => {
+ 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 = new FakeKeyboardEvent(LEFT_ARROW, lastNativeChip) as any;
+ 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('RIGHT ARROW focuses next item', () => {
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let firstNativeChip = nativeChips[0] as HTMLElement;
+
+ let RIGHT_EVENT: KeyboardEvent =
+ new FakeKeyboardEvent(RIGHT_ARROW, firstNativeChip) as any;
+ 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: KeyboardEvent = new FakeKeyboardEvent(SPACE, firstNativeChip) as any;
- let firstChip: MdChip = chips.toArray()[0];
+ it('RIGHT ARROW focuses previous item', () => {
+ fixture.detectChanges();
- spyOn(testComponent, 'chipSelect');
- spyOn(testComponent, 'chipDeselect');
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement;
- // Make sure we have the first chip focused
- chipListInstance.focus();
+ let RIGHT_EVENT: KeyboardEvent =
+ new FakeKeyboardEvent(RIGHT_ARROW, lastNativeChip) as any;
+ let array = chips.toArray();
+ let lastIndex = array.length - 1;
+ let lastItem = array[lastIndex];
- // Use the spacebar to select the chip
- chipListInstance._keydown(SPACE_EVENT);
- fixture.detectChanges();
+ // Focus the last item in the array
+ lastItem.focus();
+ expect(manager.activeItemIndex).toEqual(lastIndex);
- expect(firstChip.selected).toBeTruthy();
- expect(testComponent.chipSelect).toHaveBeenCalledTimes(1);
- expect(testComponent.chipSelect).toHaveBeenCalledWith(0);
+ // Press the RIGHT arrow
+ chipListInstance._keydown(RIGHT_EVENT);
+ fixture.detectChanges();
- // Use the spacebar to deselect the chip
- chipListInstance._keydown(SPACE_EVENT);
- fixture.detectChanges();
+ // It focuses the next-to-last item
+ expect(manager.activeItemIndex).toEqual(lastIndex - 1);
+ });
+
+ it('LEFT ARROW focuses next item', () => {
+ let nativeChips = chipListNativeElement.querySelectorAll('md-chip');
+ let firstNativeChip = nativeChips[0] as HTMLElement;
+
+ let LEFT_EVENT: KeyboardEvent = new FakeKeyboardEvent(LEFT_ARROW, firstNativeChip) as any;
+ 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);
+ });
- expect(firstChip.selected).toBeFalsy();
- expect(testComponent.chipDeselect).toHaveBeenCalledTimes(1);
- expect(testComponent.chipDeselect).toHaveBeenCalledWith(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: KeyboardEvent = new FakeEvent(SPACE) as KeyboardEvent;
- let firstChip: MdChip = chips.toArray()[0];
+ describe('when the input has focus', () => {
- spyOn(testComponent, 'chipSelect');
+ it('DELETE focuses the last chip', () => {
+ let nativeInput = chipListNativeElement.querySelector('input');
+ let DELETE_EVENT: KeyboardEvent = new FakeKeyboardEvent(DELETE, nativeInput) as any;
- // Make sure we have the first chip focused
- chipListInstance.focus();
+ // Focus the input
+ nativeInput.focus();
+ expect(manager.activeItemIndex).toBeFalsy();
- // 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('BACKSPACE focuses the last chip', () => {
+ let nativeInput = chipListNativeElement.querySelector('input');
+ let BACKSPACE_EVENT: KeyboardEvent = new FakeKeyboardEvent(BACKSPACE, nativeInput) as any;
+
+ // Focus the input
+ nativeInput.focus();
+ expect(manager.activeItemIndex).toBeFalsy();
+
+ // 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: `
-
+
@@ -231,9 +312,8 @@ describe('MdChipList', () => {
`
})
-class StaticChipList {
+class StandardChipList {
name: string = 'Test';
- selectable: boolean = true;
remove: Number;
chipSelect(index: Number) {
@@ -242,3 +322,19 @@ class StaticChipList {
chipDeselect(index: Number) {
}
}
+
+@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 7c4a396cf84c..8fd4457f8efa 100644
--- a/src/lib/chips/chip-list.ts
+++ b/src/lib/chips/chip-list.ts
@@ -4,15 +4,17 @@ import {
Component,
ContentChildren,
ElementRef,
- Input,
QueryList,
+ Renderer,
ViewEncapsulation
} from '@angular/core';
import {MdChip} from './chip';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
-import {coerceBooleanProperty} from '../core/coercion/boolean-property';
-import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
+import {
+ LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE, UP_ARROW, DOWN_ARROW
+} from '../core/keyboard/keycodes';
+import {Dir} from '../core/rtl/dir';
/**
* A material design chips component (named ChipList for it's similarity to the List component).
@@ -29,13 +31,12 @@ import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
selector: 'md-chip-list, mat-chip-list',
template: `
`,
host: {
- // Properties
- 'tabindex': '0',
'role': 'listbox',
'[class.mat-chip-list]': 'true',
- // Events
- '(focus)': 'focus()',
+ '[attr.tabindex]': '_tabIndex',
+
+ '(focus)': 'focus($event)',
'(keydown)': '_keydown($event)'
},
queries: {
@@ -47,11 +48,17 @@ import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
})
export class MdChipList implements AfterContentInit {
+ /** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
+ protected _destroyedIndex: number = null;
+
/** Track which chips we're listening to for focus/destruction. */
- private _subscribed: WeakMap
= new WeakMap();
+ protected _subscribed: WeakMap = new WeakMap();
- /** Whether or not the chip is selectable. */
- protected _selectable: boolean = true;
+ /** Holds our current input if provided. */
+ protected _inputElement: HTMLInputElement;
+
+ /** Whether or not the chip list is currently focusable via keyboard interaction. */
+ _tabIndex = -1;
/** The FocusKeyManager which handles focus. */
_keyManager: FocusKeyManager;
@@ -59,7 +66,9 @@ export class MdChipList implements AfterContentInit {
/** The chip components contained within this chip list. */
chips: QueryList;
- constructor(private _elementRef: ElementRef) { }
+ constructor(protected _renderer: Renderer, protected _elementRef: ElementRef,
+ protected _dir: Dir) {
+ }
ngAfterContentInit(): void {
this._keyManager = new FocusKeyManager(this.chips).withWrap();
@@ -67,77 +76,95 @@ export class MdChipList implements AfterContentInit {
// Go ahead and subscribe all of the initial chips
this._subscribeChips(this.chips);
+ // Make sure we set our tab index at the start
+ this._checkTabIndex();
+
// 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._checkTabIndex();
+
+ // Check to see if we have a destroyed chip and need to refocus
+ this._checkDestroyedFocus();
});
}
/**
- * Whether or not this chip is selectable. When a chip is not selectable,
- * it's selected state is always ignored.
+ * Associates an HTML input element with this chip list.
+ *
+ * @param inputElement The input to associate.
*/
- @Input() get selectable(): boolean {
- return this._selectable;
- }
-
- set selectable(value: boolean) {
- this._selectable = coerceBooleanProperty(value);
+ registerInput(inputElement: HTMLInputElement) {
+ this._inputElement = inputElement;
}
/**
- * Programmatically focus the chip list. This in turn focuses the first
- * non-disabled chip in this chip list.
+ * Programmatically focus the chip list. This in turn focuses the first non-disabled chip in this
+ * chip list, or the input if available and there are 0 chips.
+ *
+ * TODO: ARIA says this should focus the first `selected` chip if any are selected.
*/
- focus() {
- // TODO: ARIA says this should focus the first `selected` chip.
- this._keyManager.setFirstItemActive();
+ focus(event?: Event) {
+ if (this.chips.length > 0) {
+ this._keyManager.setFirstItemActive();
+ } else {
+ this.focusInput();
+ }
+ }
+
+ /** Attempt to focus an input if we have one. */
+ focusInput() {
+ if (this._inputElement) {
+ this._inputElement.focus();
+ }
}
- /** Passes relevant key presses to our key manager. */
+ /**
+ * 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 = MdChipList._isInputEmpty(target);
+ let isRtl = this._dir.value == 'rtl';
- // 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);
- }
- }
- }
+ 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);
+ let isForwardKey = (code == DOWN_ARROW || isNextKey);
- /** Toggles the selected state of the currently focused chip. */
- protected _toggleSelectOnFocusedChip(): void {
- // Allow disabling of chip selection
- if (!this.selectable) {
+ // 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 (this._isValidIndex(focusedIndex)) {
- let focusedChip: MdChip = this.chips.toArray()[focusedIndex];
+ // If they are on an empty input and hit the right arrow, wrap focus to the first chip
+ if (isInputEmpty && isForwardKey) {
+ this._keyManager.setFirstItemActive();
+ event.preventDefault();
+ return;
+ }
- 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);
}
}
}
@@ -148,10 +175,18 @@ export class MdChipList implements AfterContentInit {
*
* @param chips The list of chips to be subscribed.
*/
- protected _subscribeChips(chips: QueryList): void {
+ protected _subscribeChips(chips: QueryList < MdChip >): void {
chips.forEach(chip => this._addChip(chip));
}
+ /**
+ * Check the tab index as you should not be allowed to focus an empty list.
+ */
+ protected _checkTabIndex(): 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
@@ -175,17 +210,12 @@ export class MdChipList implements AfterContentInit {
}
});
- // 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)) {
- // 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) && this._keyManager.activeItemIndex == chipIndex) {
+ this._destroyedIndex = chipIndex;
}
this._subscribed.delete(chip);
@@ -195,6 +225,32 @@ export class MdChipList implements AfterContentInit {
this._subscribed.set(chip, true);
}
+ /**
+ * Checks to see if a focus chip was recently destroyed so that we can refocus the next closest
+ * one.
+ */
+ protected _checkDestroyedFocus() {
+ let chipsArray = this.chips.toArray();
+ let focusChip: MdChip;
+
+ if (this._destroyedIndex != null && chipsArray.length > 0) {
+ // Check whether the destroyed chip was the last item
+ if (this._destroyedIndex >= chipsArray.length) {
+ this._keyManager.setActiveItem(chipsArray.length - 1);
+ } else if (this._destroyedIndex >= 0) {
+ this._keyManager.setActiveItem(this._destroyedIndex);
+ }
+
+ // Focus the chip
+ if (focusChip) {
+ focusChip.focus();
+ }
+ }
+
+ // Reset our destroyed index
+ this._destroyedIndex = null;
+ }
+
/**
* Utility to ensure all indexes are valid.
*
@@ -205,4 +261,14 @@ export class MdChipList implements AfterContentInit {
return index >= 0 && index < this.chips.length;
}
+ /** Utility to check if an input element has no value. */
+ private static _isInputEmpty(element: HTMLElement): boolean {
+ if (element && element.nodeName.toLowerCase() == 'input') {
+ let input = element as HTMLInputElement;
+
+ return input.value == '' || input.value == null;
+ }
+
+ 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..276acb9c7f22
--- /dev/null
+++ b/src/lib/chips/chip-remove.spec.ts
@@ -0,0 +1,77 @@
+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('applies the `mat-chip-remove` CSS class', () => {
+ let hrefElement = chipNativeElement.querySelector('a');
+
+ expect(hrefElement.classList).toContain('mat-chip-remove');
+ });
+
+ it('emits (remove) on click', () => {
+ let hrefElement = chipNativeElement.querySelector('a');
+
+ testChip.removable = true;
+ fixture.detectChanges();
+
+ spyOn(testChip, 'didRemove');
+
+ hrefElement.click();
+
+ expect(testChip.didRemove).toHaveBeenCalled();
+ });
+
+ it(`monitors the parent chip's [removable] property`, () => {
+ let hrefElement = chipNativeElement.querySelector('a');
+
+ testChip.removable = true;
+ fixture.detectChanges();
+
+ expect(hrefElement.classList).not.toContain('mat-chip-remove-hidden');
+
+ testChip.removable = false;
+ fixture.detectChanges();
+
+ expect(hrefElement.classList).toContain('mat-chip-remove-hidden');
+ });
+ });
+});
+
+@Component({
+ template: `
+
+ `
+})
+class TestChip {
+ removable: boolean;
+
+ didRemove() {}
+}
diff --git a/src/lib/chips/chip-remove.ts b/src/lib/chips/chip-remove.ts
new file mode 100644
index 000000000000..88c8283b5116
--- /dev/null
+++ b/src/lib/chips/chip-remove.ts
@@ -0,0 +1,66 @@
+import {Directive, Renderer, ElementRef, OnInit, OnDestroy} from '@angular/core';
+import {MdChip} from './chip';
+import {Subscription} from 'rxjs';
+
+/**
+ * Applies proper (click) support and adds styling for use with the Material Design "cancel" icon
+ * available at https://material.io/icons/#ic_cancel.
+ *
+ * Example:
+ *
+ *
+ * clear
+ *
+ *
+ * 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: '[md-chip-remove], [mat-chip-remove], [mdChipRemove], [matChipRemove]',
+ host: {
+ '[class.mat-chip-remove]': 'true',
+ '[class.mat-chip-remove-hidden]': '!_isVisible',
+ '(click)': '_handleClick($event)'
+ }
+})
+export class MdChipRemove implements OnInit, OnDestroy {
+
+ /** Whether or not the remove icon is visible. */
+ _isVisible: boolean = false;
+
+ /** Subscription for our onRemoveChange Observable */
+ _onRemoveChangeSubscription: Subscription;
+
+ constructor(protected _renderer: Renderer, protected _elementRef: ElementRef,
+ protected _parentChip: MdChip) {
+ if (this._parentChip) {
+ this._onRemoveChangeSubscription = this._parentChip.onRemovableChange$
+ .subscribe((value: boolean) => {
+ this._updateParent(value);
+ });
+ }
+ }
+
+ ngOnInit() {
+ this._updateParent(true);
+ }
+
+ ngOnDestroy() {
+ this._updateParent(false);
+ this._onRemoveChangeSubscription.unsubscribe();
+ }
+
+ /** Calls the parent chip's public `remove()` method if applicable. */
+ _handleClick(event: Event) {
+ if (this._parentChip.removable) {
+ this._parentChip.remove();
+ }
+ }
+
+ /** Informs the parent chip whether or not it contains a remove icon. */
+ _updateParent(isRemovable: boolean) {
+ this._isVisible = isRemovable;
+ this._parentChip._setHasRemoveIcon(isRemovable);
+ }
+
+}
diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts
index c780f74a1f71..018963bed51e 100644
--- a/src/lib/chips/chip.spec.ts
+++ b/src/lib/chips/chip.spec.ts
@@ -2,6 +2,9 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdChipList, MdChip, MdChipEvent, MdChipsModule} from './index';
+import {FakeEvent} from '../core/a11y/list-key-manager.spec';
+import {SPACE, DELETE, BACKSPACE} from '../core/keyboard/keycodes';
+import {Dir} from '../core/rtl/dir';
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.forRoot()],
+ imports: [MdChipsModule],
declarations: [
BasicChip, SingleChip
- ]
+ ],
+ providers: [{
+ provide: Dir, 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.componentInstance;
+ testComponent = fixture.debugElement.componentInstance;
- chipDebugElement = fixture.debugElement.query(By.directive(MdChip));
- chipListNativeElement = fixture.debugElement.query(By.directive(MdChipList)).nativeElement;
- chipNativeElement = chipDebugElement.nativeElement;
- chipInstance = chipDebugElement.componentInstance;
- 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('SPACE selects/deselects the currently focused chip', () => {
+ const SPACE_EVENT: KeyboardEvent = new FakeEvent(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 = new FakeEvent(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 = new FakeEvent(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 = new FakeEvent(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 = new FakeEvent(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 = new FakeEvent(BACKSPACE) as KeyboardEvent;
+
+ spyOn(testComponent, 'chipRemove');
+
+ // Use the delete to remove the chip
+ chipInstance._handleKeydown(BACKSPACE_EVENT);
+ fixture.detectChanges();
+
+ expect(testComponent.chipRemove).not.toHaveBeenCalled();
+ });
});
});
@@ -121,9 +256,11 @@ describe('Chips', () => {
template: `
-
+ (select)="chipSelect($event)" (deselect)="chipDeselect($event)"
+ (remove)="chipRemove($event)">
{{name}}
@@ -133,6 +270,8 @@ class SingleChip {
name: string = 'Test';
color: string = 'primary';
selected: boolean = false;
+ selectable: boolean = true;
+ removable: boolean = true;
shouldShow: boolean = true;
chipFocus(event: MdChipEvent) {
@@ -146,6 +285,9 @@ class SingleChip {
chipDeselect(event: MdChipEvent) {
}
+
+ chipRemove(event: MdChipEvent) {
+ }
}
@Component({
diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts
index 73656bdfefdf..19a81f7abafd 100644
--- a/src/lib/chips/chip.ts
+++ b/src/lib/chips/chip.ts
@@ -9,8 +9,10 @@ import {
Renderer
} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
import {Focusable} from '../core/a11y/focus-key-manager';
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
+import {SPACE, BACKSPACE, DELETE} from '../core/keyboard/keycodes';
export interface MdChipEvent {
chip: MdChip;
@@ -22,17 +24,19 @@ export interface MdChipEvent {
@Component({
selector: `md-basic-chip, [md-basic-chip], md-chip, [md-chip],
mat-basic-chip, [mat-basic-chip], mat-chip, [mat-chip]`,
- template: ``,
+ template: ``,
host: {
'[class.mat-chip]': 'true',
'tabindex': '-1',
'role': 'option',
'[class.mat-chip-selected]': 'selected',
+ '[class.mat-chip-has-remove-icon]': '_hasRemoveIcon',
'[attr.disabled]': 'disabled',
'[attr.aria-disabled]': '_isAriaDisabled',
- '(click)': '_handleClick($event)'
+ '(click)': '_handleClick($event)',
+ '(keydown)': '_handleKeydown($event)'
}
})
export class MdChip implements Focusable, OnInit, OnDestroy {
@@ -40,12 +44,25 @@ export class MdChip implements Focusable, OnInit, OnDestroy {
/** Whether or not the chip is disabled. Disabled chips cannot be focused. */
protected _disabled: boolean = null;
+ /** Whether or not the chip is selectable. */
+ protected _selectable: boolean = true;
+
+ /** Whether or not the chip is removable. */
+ protected _removable: boolean = true;
+
/** Whether or not the chip is selected. */
protected _selected: boolean = false;
/** The palette color of selected chips. */
protected _color: string = 'primary';
+ /** Whether or not the chip is displaying the remove icon. */
+ _hasRemoveIcon: boolean = false;
+
+ /** Emitted when the removable property changes. */
+ private _onRemovableChange = new EventEmitter();
+ onRemovableChange$: Observable = this._onRemovableChange.asObservable();
+
/** Emitted when the chip is focused. */
onFocus = new EventEmitter();
@@ -58,7 +75,11 @@ export class MdChip implements Focusable, OnInit, OnDestroy {
/** Emitted when the chip is destroyed. */
@Output() destroy = new EventEmitter();
- constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) { }
+ /** Emitted when a chip is to be removed. */
+ @Output('remove') onRemove = new EventEmitter();
+
+ constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {
+ }
ngOnInit(): void {
this._addDefaultCSSClass();
@@ -84,6 +105,30 @@ export class MdChip implements Focusable, OnInit, OnDestroy {
return String(coerceBooleanProperty(this.disabled));
}
+ /**
+ * Whether or not the chips are selectable. When a chip is not selectable,
+ * changes to it's selected state are always ignored.
+ */
+ @Input() get selectable(): boolean {
+ return this._selectable;
+ }
+
+ set selectable(value: boolean) {
+ this._selectable = coerceBooleanProperty(value);
+ }
+
+ /**
+ * Determines whether or not the chip displays the remove styling and emits (remove) events.
+ */
+ @Input() get removable(): boolean {
+ return this._removable;
+ }
+
+ set removable(value: boolean) {
+ this._removable = coerceBooleanProperty(value);
+ this._onRemovableChange.emit(this._removable);
+ }
+
/** Whether or not this chip is selected. */
@Input() get selected(): boolean {
return this._selected;
@@ -99,10 +144,7 @@ export class MdChip implements Focusable, OnInit, OnDestroy {
}
}
- /**
- * 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;
@@ -123,24 +165,82 @@ export class MdChip implements Focusable, OnInit, OnDestroy {
this.onFocus.emit({chip: this});
}
+ /**
+ * Allows for programmatic removal of the chip. Called by the MdChipList when the DELETE or
+ * BACKSPACE keys are pressed.
+ *
+ * Note: This only informs any listeners of the removal request, it does **not** actually 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._checkDisabled(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.focus();
+ }
+
+ /** Handle custom key presses. */
+ _handleKeydown(event: KeyboardEvent) {
+ if (this._checkDisabled(event)) {
+ return;
+ }
+
+ switch (event.keyCode) {
+ case DELETE:
+ case BACKSPACE:
+ // If we are removable, remove the focused chip
+ if (this.removable) {
+ this.onRemove.emit();
+ }
+
+ // 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;
+ }
+ }
+
+ /**
+ * Sets whether or not this chip is displaying a remove icon. Adds/removes the
+ * `md-chip-has-remove-icon` class.
+ */
+ _setHasRemoveIcon(value: boolean) {
+ this._hasRemoveIcon = value;
+ }
+
+ protected _checkDisabled(event: Event): boolean {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
- } else {
- this.focus();
}
+
+ return this.disabled;
}
/** Initializes the appropriate CSS classes based on the chip type (basic or standard). */
private _addDefaultCSSClass() {
let el: HTMLElement = this._elementRef.nativeElement;
- // Always add the `mat-chip` class
- el.classList.add('mat-chip');
-
// If we are a basic chip, also add the `mat-basic-chip` class for :not() targeting
if (el.nodeName.toLowerCase() == 'mat-basic-chip' || el.hasAttribute('mat-basic-chip') ||
el.nodeName.toLowerCase() == 'md-basic-chip' || el.hasAttribute('md-basic-chip')) {
diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss
index 3ea4afb1bd6c..a541ef097dd3 100644
--- a/src/lib/chips/chips.scss
+++ b/src/lib/chips/chips.scss
@@ -1,9 +1,16 @@
-$mat-chip-vertical-padding: 8px;
-$mat-chip-horizontal-padding: 12px;
$mat-chip-font-size: 13px;
$mat-chip-line-height: 16px;
+$mat-chip-border-width: 2px;
-$mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
+$mat-chip-vertical-padding: 8px - $mat-chip-border-width;
+$mat-chip-horizontal-padding: 12px - $mat-chip-border-width;
+
+$mat-chip-margin: ($mat-chip-horizontal-padding / 4);
+
+$mat-chip-remove-margin: $mat-chip-line-height * 2;
+$mat-chip-remove-icon-offset: 3px;
+$mat-chip-remove-size: 24px;
+$mat-chip-remove-font-size: 18px;
.mat-chip-list-wrapper {
@@ -16,34 +23,37 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
* Only apply the margins to chips
*/
.mat-chip:not(.mat-basic-chip) {
- margin: 0 $mat-chips-chip-margin 0 $mat-chips-chip-margin;
-
- // Remove the margin from the first element (in both LTR and RTL)
- &:first-child {
- margin: {
- left: 0;
- right: $mat-chips-chip-margin;
- }
+ margin: $mat-chip-margin;
- [dir='rtl'] & {
+ // Do not apply the special margins inside an input container
+ :not(.mat-input-wrapper) & {
+ // Remove the margin from the first element (in both LTR and RTL)
+ &:first-child {
margin: {
- left: $mat-chips-chip-margin;
- right: 0;
+ left: 0;
+ right: $mat-chip-margin;
}
- }
- }
- // Remove the margin from the last element (in both LTR and RTL)
- &:last-child {
- margin: {
- left: $mat-chips-chip-margin;
- right: 0;
+ [dir='rtl'] & {
+ margin: {
+ left: $mat-chip-margin;
+ right: 0;
+ }
+ }
}
- [dir='rtl'] & {
+ // Remove the margin from the last element (in both LTR and RTL)
+ &:last-child {
margin: {
- left: 0;
- right: $mat-chips-chip-margin;
+ left: $mat-chip-margin;
+ right: 0;
+ }
+
+ [dir='rtl'] & {
+ margin: {
+ left: 0;
+ right: $mat-chip-margin;
+ }
}
}
}
@@ -52,11 +62,18 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
.mat-chip:not(.mat-basic-chip) {
display: inline-block;
- padding: $mat-chip-vertical-padding $mat-chip-horizontal-padding $mat-chip-vertical-padding $mat-chip-horizontal-padding;
+ position: relative;
+
+ padding: $mat-chip-vertical-padding $mat-chip-horizontal-padding;
+ border: $mat-chip-border-width solid transparent;
border-radius: $mat-chip-horizontal-padding * 2;
font-size: $mat-chip-font-size;
line-height: $mat-chip-line-height;
+
+ &.mat-chip-has-remove-icon {
+ padding-right: $mat-chip-remove-margin;
+ }
}
.mat-chip-list-stacked .mat-chip-list-wrapper {
@@ -77,3 +94,31 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4;
}
}
}
+
+.mat-chip-remove {
+ position: absolute;
+ top: $mat-chip-border-width;
+ right: $mat-chip-border-width * 2;
+ width: $mat-chip-remove-size;
+ height: $mat-chip-remove-size - $mat-chip-remove-icon-offset;
+ padding-top: $mat-chip-remove-icon-offset;
+ font-size: $mat-chip-remove-font-size;
+ text-align: center;
+ cursor: default;
+}
+
+.mat-chip-remove.mat-chip-remove-hidden {
+ display: none;
+}
+
+// Override a few styles when inside an mat-input-container
+.mat-input-container .mat-chip-list-wrapper input {
+ width: auto;
+ height: 38px;
+ margin-left: 8px;
+}
+
+// Fix the label offset
+.mat-input-container mat-chip-list ~ label.mat-empty {
+ transform: translateY(22px);
+}
\ No newline at end of file
diff --git a/src/lib/chips/index.ts b/src/lib/chips/index.ts
index 6b6d4e4e3192..9bcbb181e01a 100644
--- a/src/lib/chips/index.ts
+++ b/src/lib/chips/index.ts
@@ -1,12 +1,18 @@
import {NgModule, ModuleWithProviders} from '@angular/core';
import {MdChipList} from './chip-list';
import {MdChip} from './chip';
+import {MdChipInput} from './chip-input';
+import {MdChipRemove} from './chip-remove';
+export * from './chip-list';
+export * from './chip';
+export * from './chip-input';
+export * from './chip-remove';
@NgModule({
imports: [],
- exports: [MdChipList, MdChip],
- declarations: [MdChipList, MdChip]
+ exports: [MdChipList, MdChip, MdChipInput, MdChipRemove],
+ declarations: [MdChipList, MdChip, MdChipInput, MdChipRemove]
})
export class MdChipsModule {
/** @deprecated */
@@ -17,7 +23,3 @@ export class MdChipsModule {
};
}
}
-
-
-export * from './chip-list';
-export * from './chip';
diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts
index 6056b7a97ec0..067d7d73b2e8 100644
--- a/src/lib/core/keyboard/keycodes.ts
+++ b/src/lib/core/keyboard/keycodes.ts
@@ -18,6 +18,7 @@ export const END = 35;
export const ENTER = 13;
export const SPACE = 32;
export const TAB = 9;
+export const COMMA = 188;
export const ESCAPE = 27;
export const BACKSPACE = 8;