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 @@

Advanced

Selected/Colored + + (destroy)="alert('chip destroyed')" (remove)="toggleVisible()"> + cancel With Events @@ -37,16 +39,37 @@

Advanced

Input Container

- - - {{person.name}} - - +

+ You can easily put the the <md-chip-list> inside of an + <md-input-container>. +

+ + + + + {{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 07e007d5509f..76c4030d1889 100644 --- a/src/demo-app/chips/chips-demo.ts +++ b/src/demo-app/chips/chips-demo.ts @@ -1,4 +1,5 @@ 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' }, @@ -43,6 +50,28 @@ export class ChipsDemo { if (input.value && input.value.trim() != '') { this.people.push({ name: input.value.trim() }); input.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); +>>>>>>> feat(chips): Add remove functionality/styling. } } diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss index b55f5d1dd7f2..196b8519232c 100644 --- a/src/lib/chips/_chips-theme.scss +++ b/src/lib/chips/_chips-theme.scss @@ -22,21 +22,50 @@ $mat-chip-line-height: 16px; // 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); color: mat-color($primary, default-contrast); @@ -50,6 +79,45 @@ $mat-chip-line-height: 16px; &.mat-warn { background-color: mat-color($warn); color: mat-color($warn, default-contrast); + 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 6664d3c88c0e..c982ea4a23c6 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -6,179 +6,236 @@ import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {SPACE, LEFT_ARROW, RIGHT_ARROW, TAB} from '../core/keyboard/keycodes'; import {createKeyboardEvent} from '../core/testing/event-objects'; +import {MdInputModule} from '../input/index'; +import {FakeEvent} from '../core/a11y/list-key-manager.spec'; +import {LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE} from '../core/keyboard/keycodes'; +import {Dir} from '../core/rtl/dir'; + +export class FakeKeyboardEvent extends FakeEvent { + constructor(keyCode: number, protected target: HTMLElement) { + super(keyCode); + + this.target = target; + } +} describe('MdChipList', () => { let fixture: ComponentFixture; 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], - declarations: [StaticChipList] + imports: [MdChipsModule, MdInputModule], + declarations: [ + StaticChipList, StandardChipList, InputContainerChipList + ], + providers: [{ + provide: Dir, 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; - }); + describe('StandardChipList', () => { - it('focuses the first chip on focus', () => { - chipListInstance.focus(); - fixture.detectChanges(); + describe('basic behaviors', () => { + beforeEach(async(() => { + setupStandardList(); + })); - 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]; - - lastItem.focus(); - fixture.detectChanges(); - - expect(manager.activeItemIndex).toBe(lastIndex); - }); + describe('focus behaviors', () => { + beforeEach(async(() => { + setupStandardList(); + })); - 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]; - it('left arrow focuses previous item', () => { - let nativeChips = chipListNativeElement.querySelectorAll('md-chip'); - let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; + // Focus the middle item + midItem.focus(); + + // Destroy the middle item + testComponent.remove = 2; + fixture.detectChanges(); + + // It focuses the 4th item (now at index 2) + expect(manager.activeItemIndex).toEqual(2); + }); 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('focuses the previous item', () => { + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[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 = 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); + it('LEFT ARROW focuses previous item', () => { + let nativeChips = chipListNativeElement.querySelectorAll('md-chip'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; - // Press the RIGHT arrow - chipListInstance._keydown(RIGHT_EVENT); - fixture.detectChanges(); + 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 next-to-last item - expect(manager.activeItemIndex).toEqual(1); - }); + // Focus the last item in the array + lastItem.focus(); + expect(manager.activeItemIndex).toEqual(lastIndex); + + // Press the LEFT arrow + chipListInstance._keydown(LEFT_EVENT); + fixture.detectChanges(); + + // It focuses the next-to-last item + expect(manager.activeItemIndex).toEqual(lastIndex - 1); + }); + + it('RIGHT ARROW focuses next item', () => { + let nativeChips = chipListNativeElement.querySelectorAll('md-chip'); + let firstNativeChip = nativeChips[0] as HTMLElement; + + let RIGHT_EVENT: 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 = createKeyboardEvent('keydown', SPACE, firstNativeChip); let firstChip: MdChip = chips.toArray()[0]; - spyOn(testComponent, 'chipSelect'); - spyOn(testComponent, 'chipDeselect'); + it('RIGHT ARROW focuses previous item', () => { + fixture.detectChanges(); - // Make sure we have the first chip focused - chipListInstance.focus(); + let nativeChips = chipListNativeElement.querySelectorAll('md-chip'); + let lastNativeChip = nativeChips[nativeChips.length - 1] as HTMLElement; - // Use the spacebar to select the chip - chipListInstance._keydown(SPACE_EVENT); - fixture.detectChanges(); + let RIGHT_EVENT: KeyboardEvent = + new FakeKeyboardEvent(RIGHT_ARROW, lastNativeChip) as any; + let array = chips.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; - expect(firstChip.selected).toBeTruthy(); - expect(testComponent.chipSelect).toHaveBeenCalledTimes(1); - expect(testComponent.chipSelect).toHaveBeenCalledWith(0); + // Focus the last item in the array + lastItem.focus(); + expect(manager.activeItemIndex).toEqual(lastIndex); - // Use the spacebar to deselect the chip - chipListInstance._keydown(SPACE_EVENT); - fixture.detectChanges(); + // Press the RIGHT arrow + chipListInstance._keydown(RIGHT_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); }); it('allow focus to escape when tabbing away', fakeAsync(() => { @@ -192,38 +249,90 @@ describe('MdChipList', () => { 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]; + }); - spyOn(testComponent, 'chipSelect'); + describe('when the input has focus', () => { - // Make sure we have the first chip focused - chipListInstance.focus(); + it('DELETE focuses the last chip', () => { + let nativeInput = chipListNativeElement.querySelector('input'); + let DELETE_EVENT: KeyboardEvent = new FakeKeyboardEvent(DELETE, nativeInput) as any; - // Use the spacebar to attempt to select the chip - chipListInstance._keydown(SPACE_EVENT); - fixture.detectChanges(); + // Focus the input + nativeInput.focus(); + expect(manager.activeItemIndex).toBeFalsy(); + + // 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: ` - +

@@ -233,9 +342,8 @@ describe('MdChipList', () => {
` }) -class StaticChipList { +class StandardChipList { name: string = 'Test'; - selectable: boolean = true; remove: Number; chipSelect(index: Number) { @@ -244,3 +352,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 276258f699e2..9297cd4e142c 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -7,6 +7,9 @@ import { QueryList, ViewEncapsulation, OnDestroy, + ElementRef, + QueryList, + Renderer2, } from '@angular/core'; import {MdChip} from './chip'; @@ -14,6 +17,10 @@ import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {coerceBooleanProperty} from '../core/coercion/boolean-property'; import {SPACE, LEFT_ARROW, RIGHT_ARROW, TAB} from '../core/keyboard/keycodes'; import {Subscription} from 'rxjs/Subscription'; +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). @@ -35,8 +42,9 @@ import {Subscription} from 'rxjs/Subscription'; 'role': 'listbox', '[class.mat-chip-list]': 'true', - // Events - '(focus)': 'focus()', + '[attr.tabindex]': '_tabIndex', + + '(focus)': 'focus($event)', '(keydown)': '_keydown($event)' }, queries: { @@ -48,8 +56,11 @@ import {Subscription} from 'rxjs/Subscription'; }) export class MdChipList implements AfterContentInit, OnDestroy { + /** 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(); /** Subscription to tabbing out from the chip list. */ private _tabOutSubscription: Subscription; @@ -57,6 +68,11 @@ export class MdChipList implements AfterContentInit, OnDestroy { /** Whether or not the chip is selectable. */ protected _selectable: boolean = true; + protected _inputElement: HTMLInputElement; + + /** Whether or not the chip list is currently focusable via keyboard interaction. */ + _tabIndex = -1; + /** The FocusKeyManager which handles focus. */ _keyManager: FocusKeyManager; @@ -66,6 +82,10 @@ export class MdChipList implements AfterContentInit, OnDestroy { /** Tab index for the chip list. */ _tabIndex = 0; + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef, + protected _dir: Dir) { + } + ngAfterContentInit(): void { this._keyManager = new FocusKeyManager(this.chips).withWrap(); @@ -79,9 +99,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._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(); }); } @@ -92,8 +126,9 @@ export class MdChipList implements AfterContentInit, OnDestroy { } /** - * 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; } @@ -101,59 +136,70 @@ export class MdChipList implements AfterContentInit, OnDestroy { 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(); + } } - /** 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 = MdChipList._isInputEmpty(target); + let isRtl = 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); + let isForwardKey = (code == DOWN_ARROW || isNextKey); + + // 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); } } } @@ -164,10 +210,18 @@ export class MdChipList implements AfterContentInit, OnDestroy { * * @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 @@ -191,17 +245,12 @@ 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)) { - // 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); @@ -211,6 +260,32 @@ export class MdChipList implements AfterContentInit, OnDestroy { 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. * @@ -221,4 +296,14 @@ export class MdChipList implements AfterContentInit, OnDestroy { 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 8ed9bc736ee4..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], 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 5e06c3839994..35e90ff0c430 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -9,8 +9,10 @@ import { Renderer2, } 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,6 +75,9 @@ export class MdChip implements Focusable, OnInit, OnDestroy { /** Emitted when the chip is destroyed. */ @Output() destroy = new EventEmitter(); + /** Emitted when a chip is to be removed. */ + @Output('remove') onRemove = new EventEmitter(); + constructor(protected _renderer: Renderer2, protected _elementRef: ElementRef) { } ngOnInit(): void { @@ -84,6 +104,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 +143,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,15 +164,76 @@ 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). */ diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss index 7de2395d833c..1ed4a54d0dad 100644 --- a/src/lib/chips/chips.scss +++ b/src/lib/chips/chips.scss @@ -1,18 +1,69 @@ $mat-chip-vertical-padding: 8px; $mat-chip-horizontal-padding: 12px; -$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 { display: flex; flex-direction: row; flex-wrap: wrap; align-items: flex-start; + + /* + * Only apply the margins to chips + */ + .mat-chip:not(.mat-basic-chip) { + margin: $mat-chip-margin; + + // 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: 0; + right: $mat-chip-margin; + } + + [dir='rtl'] & { + margin: { + left: $mat-chip-margin; + right: 0; + } + } + } + + // Remove the margin from the last element (in both LTR and RTL) + &:last-child { + margin: { + left: $mat-chip-margin; + right: 0; + } + + [dir='rtl'] & { + margin: { + left: 0; + right: $mat-chip-margin; + } + } + } + } + } } .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; // Apply a margin to adjacent sibling chips. @@ -23,6 +74,13 @@ $mat-chips-chip-margin: $mat-chip-horizontal-padding / 4; margin: 0 $mat-chips-chip-margin 0 0; } } + + 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 { @@ -43,3 +101,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); +} diff --git a/src/lib/chips/index.ts b/src/lib/chips/index.ts index 22024f795f18..99826ace423d 100644 --- a/src/lib/chips/index.ts +++ b/src/lib/chips/index.ts @@ -1,12 +1,18 @@ import {NgModule} 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 {} 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;