diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 859d638695f6..d068c4b50e61 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -23,11 +23,14 @@

Advanced

Selected/Colored + + (destroy)="displayMessage('chip destroyed')" (remove)="toggleVisible()"> With Events + cancel +
{{message}}
@@ -37,16 +40,61 @@

Advanced

Input Container

- - +

+ 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(); }