diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 5100ef5f31fc..d3ba62b3824f 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -1,68 +1,65 @@
-
-

Static Chips

- -
Simple
- - - Chip 1 - Chip 2 - Chip 3 - - -
Advanced
- - - Selected/Colored - - With Events - - - -
Unstyled
- - - Basic Chip 1 - Basic Chip 2 - Basic Chip 3 - - -

Material Contributors

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

Stacked Chips

- -

- You can also stack the chips if you want them on top of each other. -

- - - - None - - - - Primary - - - - Accent - - - - Warn - - -
+ + Static Chips + + +

Simple

+ + + Chip 1 + Chip 2 + Chip 3 + + +

Unstyled

+ + + Basic Chip 1 + Basic Chip 2 + Basic Chip 3 + + +

Advanced

+ + + Selected/Colored + + With Events + + +
+
+ + + Dynamic Chips + + +

Input Container

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

Stacked Chips

+ +

+ You can also stack the chips if you want them on top of each other and/or use the + (focus) event to run custom code. +

+ + + + {{aColor.name}} + + +
+
\ No newline at end of file diff --git a/src/demo-app/chips/chips-demo.scss b/src/demo-app/chips/chips-demo.scss index 46e1d249941e..f0ba734b465f 100644 --- a/src/demo-app/chips/chips-demo.scss +++ b/src/demo-app/chips/chips-demo.scss @@ -4,6 +4,19 @@ max-width: 200px; } + md-card { + padding: 0; + margin: 16px; + + & md-toolbar { + margin: 0; + } + + & md-card-content { + padding: 24px; + } + } + md-basic-chip { margin: auto 10px; } diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts index 45ac721ff490..16135f235cf0 100644 --- a/src/demo-app/chips/chips-demo.ts +++ b/src/demo-app/chips/chips-demo.ts @@ -5,6 +5,11 @@ export interface Person { name: string; } +export interface DemoColor { + name: string; + color: string; +} + @Component({ moduleId: module.id, selector: 'chips-demo', @@ -24,6 +29,13 @@ export class ChipsDemo { { name: 'Paul' } ]; + availableColors: DemoColor[] = [ + { name: 'none', color: '' }, + { name: 'Primary', color: 'primary' }, + { name: 'Accent', color: 'accent' }, + { name: 'Warn', color: 'warn' } + ]; + alert(message: string): void { alert(message); } diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss index 9fa1b60b2240..3dd5dccfc8e2 100644 --- a/src/lib/chips/_chips-theme.scss +++ b/src/lib/chips/_chips-theme.scss @@ -1,3 +1,4 @@ +@import '../core/theming/palette'; @import '../core/theming/theming'; @mixin md-chips-theme($theme) { @@ -6,27 +7,40 @@ $accent: map-get($theme, accent); $warn: map-get($theme, warn); $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + // Use spec-recommended color for regular foreground, and utilise contrast color for a grey very + // close to the selected spec since no guidance is provided and to ensure palette consistency. + $light-foreground: rgba(0, 0, 0, 0.87); + $light-selected-foreground: md-contrast($md-grey, 600); + + // 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, md-color($background, card), #e0e0e0); + $unselected-foreground: if($is-dark-theme, md-color($foreground, text), $light-foreground); + + $selected-background: if($is-dark-theme, md-color($background, app-bar), #808080); + $selected-foreground: if($is-dark-theme, md-color($foreground, text), $light-selected-foreground); .md-chip { - background-color: #e0e0e0; - color: rgba(0, 0, 0, 0.87); + background-color: $unselected-background; + color: $unselected-foreground; } - .md-chip.selected { - // There is no dark theme in the current spec, so this applies to both - background-color: #808080; - - // Use a contrast color for a grey very close to the background color - color: md-contrast($md-grey, 600); + .md-chip.md-chip-selected { + background-color: $selected-background; + color: $selected-foreground; &.md-primary { background-color: md-color($primary, 500); color: md-contrast($primary, 500); } + &.md-accent { background-color: md-color($accent, 500); color: md-contrast($accent, 500); } + &.md-warn { background-color: md-color($warn, 500); color: md-contrast($warn, 500); diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index 01daa7be7bd9..3552f90996f6 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -3,6 +3,8 @@ import {Component, DebugElement, QueryList} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdChip, MdChipList, MdChipsModule} from './index'; import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {FakeEvent} from '../core/a11y/list-key-manager.spec'; +import {SPACE} from '../core/keyboard/keycodes'; describe('MdChipList', () => { let fixture: ComponentFixture; @@ -10,7 +12,7 @@ describe('MdChipList', () => { let chipListNativeElement: HTMLElement; let chipListInstance: MdChipList; let testComponent: StaticChipList; - let items: QueryList; + let chips: QueryList; let manager: ListKeyManager; beforeEach(async(() => { @@ -22,9 +24,7 @@ describe('MdChipList', () => { }); TestBed.compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(StaticChipList); fixture.detectChanges(); @@ -32,7 +32,8 @@ describe('MdChipList', () => { chipListNativeElement = chipListDebugElement.nativeElement; chipListInstance = chipListDebugElement.componentInstance; testComponent = fixture.debugElement.componentInstance; - }); + chips = chipListInstance.chips; + })); describe('basic behaviors', () => { it('adds the `md-chip-list` class', () => { @@ -42,12 +43,20 @@ describe('MdChipList', () => { describe('focus behaviors', () => { beforeEach(() => { - items = chipListInstance.chips; manager = chipListInstance._keyManager; }); + it('focuses the first chip on focus', () => { + let FOCUS_EVENT: Event = {} as Event; + + chipListInstance.focus(FOCUS_EVENT); + fixture.detectChanges(); + + expect(manager.focusedItemIndex).toBe(0); + }); + it('watches for chip focus', () => { - let array = items.toArray(); + let array = chips.toArray(); let lastIndex = array.length - 1; let lastItem = array[lastIndex]; @@ -59,7 +68,7 @@ describe('MdChipList', () => { describe('on chip destroy', () => { it('focuses the next item', () => { - let array = items.toArray(); + let array = chips.toArray(); let midItem = array[2]; // Focus the middle item @@ -74,7 +83,7 @@ describe('MdChipList', () => { }); it('focuses the previous item', () => { - let array = items.toArray(); + let array = chips.toArray(); let lastIndex = array.length - 1; let lastItem = array[lastIndex]; @@ -91,20 +100,89 @@ describe('MdChipList', () => { }); }); + describe('keyboard behavior', () => { + + describe('when selectable is true', () => { + beforeEach(() => { + testComponent.selectable = true; + fixture.detectChanges(); + }); + + it('SPACE selects/deselects the currently focused chip', () => { + let FOCUS_EVENT: Event = {} as Event; + let SPACE_EVENT: KeyboardEvent = new FakeEvent(SPACE) as KeyboardEvent; + let firstChip: MdChip = chips.toArray()[0]; + + spyOn(testComponent, 'chipSelect'); + spyOn(testComponent, 'chipDeselect'); + + // Make sure we have the first chip focused + chipListInstance.focus(FOCUS_EVENT); + + // Use the spacebar to select the chip + chipListInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(firstChip.selected).toBeTruthy(); + expect(testComponent.chipSelect).toHaveBeenCalledTimes(1); + expect(testComponent.chipSelect).toHaveBeenCalledWith(0); + + // Use the spacebar to deselect the chip + chipListInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(firstChip.selected).toBeFalsy(); + expect(testComponent.chipDeselect).toHaveBeenCalledTimes(1); + expect(testComponent.chipDeselect).toHaveBeenCalledWith(0); + }); + }); + + describe('when selectable is false', () => { + beforeEach(() => { + testComponent.selectable = false; + fixture.detectChanges(); + }); + + it('SPACE ignores selection', () => { + let FOCUS_EVENT: Event = {} as Event; + let SPACE_EVENT: KeyboardEvent = new FakeEvent(SPACE) as KeyboardEvent; + let firstChip: MdChip = chips.toArray()[0]; + + spyOn(testComponent, 'chipSelect'); + + // Make sure we have the first chip focused + chipListInstance.focus(FOCUS_EVENT); + + // Use the spacebar to attempt to select the chip + chipListInstance._keydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(firstChip.selected).toBeFalsy(); + expect(testComponent.chipSelect).not.toHaveBeenCalled(); + }); + }); + + }); + }); @Component({ template: ` - -
{{name}} 1
-
{{name}} 2
-
{{name}} 3
-
{{name}} 4
-
{{name}} 5
-
- ` + +
+
+ + {{name}} {{i + 1}} + +
+
+
` }) class StaticChipList { - name: 'Test'; + name: string = 'Test'; + selectable: boolean = true; remove: Number; + + chipSelect(index: Number) {} + chipDeselect(index: Number) {} } diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index aebd396ac829..a37f8aab3b2b 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -4,6 +4,7 @@ import { Component, ContentChildren, ElementRef, + Input, ModuleWithProviders, NgModule, QueryList, @@ -12,6 +13,8 @@ import { import {MdChip} from './chip'; import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {coerceBooleanProperty} from '../core/coercion/boolean-property'; +import {SPACE} from '../core/keyboard/keycodes'; /** * A material design chips component (named ChipList for it's similarity to the List component). @@ -34,8 +37,8 @@ import {ListKeyManager} from '../core/a11y/list-key-manager'; 'class': 'md-chip-list', // Events - '(focus)': '_keyManager.focusFirstItem()', - '(keydown)': 'keydown($event)' + '(focus)': 'focus($event)', + '(keydown)': '_keydown($event)' }, queries: { chips: new ContentChildren(MdChip) @@ -49,29 +52,85 @@ export class MdChipList implements AfterContentInit { /** Track which chips we're listening to for focus/destruction. */ private _subscribed: WeakMap = new WeakMap(); + /** Whether or not the chip is selectable. */ + protected _selectable: boolean = true; + /** The ListKeyManager which handles focus. */ _keyManager: ListKeyManager; /** The chip components contained within this chip list. */ chips: QueryList; - constructor(private _elementRef: ElementRef) {} + constructor(private _elementRef: ElementRef) { + } ngAfterContentInit(): void { this._keyManager = new ListKeyManager(this.chips).withFocusWrap(); // Go ahead and subscribe all of the initial chips - this.subscribeChips(this.chips); + this._subscribeChips(this.chips); // When the list changes, re-subscribe this.chips.changes.subscribe((chips: QueryList) => { - this.subscribeChips(chips); + this._subscribeChips(chips); }); } + /** + * Whether or not this chip is selectable. When a chip is not selectable, + * it's selected state is always ignored. + */ + @Input() get selectable(): boolean { + return this._selectable; + } + + set selectable(value: boolean) { + this._selectable = coerceBooleanProperty(value); + } + + /** + * Programmatically focus the chip list. This in turn focuses the first non-disabled chip in this + * chip list. + * + * TODO: ARIA says this should focus the first `selected` chip. + */ + focus(event: Event) { + this._keyManager.focusFirstItem(); + } + /** Pass relevant key presses to our key manager. */ - keydown(event: KeyboardEvent) { - this._keyManager.onKeydown(event); + _keydown(event: KeyboardEvent) { + 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; + default: + this._keyManager.onKeydown(event); + } + } + + /** Toggles the selected state of the currently focused chip. */ + protected _toggleSelectOnFocusedChip(): void { + // Allow disabling of chip selection + if (!this.selectable) { + return; + } + + let focusedIndex = this._keyManager.focusedItemIndex; + + if (this._isValidIndex(focusedIndex)) { + let focusedChip: MdChip = this.chips.toArray()[focusedIndex]; + + if (focusedChip) { + focusedChip.toggleSelected(); + } + } } /** @@ -80,8 +139,8 @@ export class MdChipList implements AfterContentInit { * * @param chips The list of chips to be subscribed. */ - protected subscribeChips(chips: QueryList): void { - chips.forEach(chip => this.addChip(chip)); + protected _subscribeChips(chips: QueryList): void { + chips.forEach(chip => this._addChip(chip)); } /** @@ -92,7 +151,7 @@ export class MdChipList implements AfterContentInit { * @param chip The chip to be subscribed (or checked for existing * subscription). */ - protected addChip(chip: MdChip) { + protected _addChip(chip: MdChip) { // If we've already been subscribed to a parent, do nothing if (this._subscribed.has(chip)) { return; @@ -102,7 +161,7 @@ export class MdChipList implements AfterContentInit { chip.onFocus.subscribe(() => { let chipIndex: number = this.chips.toArray().indexOf(chip); - if (this.isValidIndex(chipIndex)) { + if (this._isValidIndex(chipIndex)) { this._keyManager.updateFocusedItemIndex(chipIndex); } }); @@ -111,7 +170,7 @@ export class MdChipList implements AfterContentInit { chip.destroy.subscribe(() => { let chipIndex: number = this.chips.toArray().indexOf(chip); - if (this.isValidIndex(chipIndex)) { + if (this._isValidIndex(chipIndex)) { // Check whether the chip is the last item if (chipIndex < this.chips.length - 1) { this._keyManager.setFocus(chipIndex); @@ -133,7 +192,7 @@ export class MdChipList implements AfterContentInit { * @param index The index to be checked. * @returns {boolean} True if the index is valid for our list of chips. */ - private isValidIndex(index: number): boolean { + private _isValidIndex(index: number): boolean { return index >= 0 && index < this.chips.length; } diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts index 0b623e3bb3ce..9797909d7c65 100644 --- a/src/lib/chips/chip.spec.ts +++ b/src/lib/chips/chip.spec.ts @@ -1,7 +1,7 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; -import {MdChipList, MdChip, MdChipsModule} from './index'; +import {MdChipList, MdChip, MdChipEvent, MdChipsModule} from './index'; describe('Chips', () => { let fixture: ComponentFixture; @@ -86,6 +86,28 @@ describe('Chips', () => { expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); }); + + it('allows color customization', () => { + expect(chipNativeElement.classList).toContain('md-primary'); + + testComponent.color = 'warn'; + fixture.detectChanges(); + + expect(chipNativeElement.classList).not.toContain('md-primary'); + expect(chipNativeElement.classList).toContain('md-warn'); + }); + + it('allows selection', () => { + spyOn(testComponent, 'chipSelect'); + expect(chipNativeElement.classList).not.toContain('md-chip-selected'); + + testComponent.selected = true; + fixture.detectChanges(); + + expect(chipNativeElement.classList).toContain('md-chip-selected'); + expect(testComponent.chipSelect).toHaveBeenCalledWith({ chip: chipInstance }); + }); + }); }); }); @@ -94,20 +116,30 @@ describe('Chips', () => { template: `
- + {{name}}
` }) class SingleChip { - name: String = 'Test'; - shouldShow: Boolean = true; + name: string = 'Test'; + color: string = 'primary'; + selected: boolean = false; + shouldShow: boolean = true; + + chipFocus(event: MdChipEvent) { + } + + chipDestroy(event: MdChipEvent) { + } - chipFocus() { + chipSelect(event: MdChipEvent) { } - chipDestroy() { + chipDeselect(event: MdChipEvent) { } } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 81cd0f48914d..823ce71fcdbf 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -17,7 +17,7 @@ export interface MdChipEvent { } /** - * A material design styled Chip component. Used inside the ChipList component. + * A material design styled Chip component. Used inside the MdChipList component. */ @Component({ selector: 'md-basic-chip, [md-basic-chip], md-chip, [md-chip]', @@ -26,6 +26,7 @@ export interface MdChipEvent { 'tabindex': '-1', 'role': 'option', + '[class.md-chip-selected]': 'selected', '[attr.disabled]': 'disabled', '[attr.aria-disabled]': '_isAriaDisabled', @@ -34,31 +35,37 @@ export interface MdChipEvent { }) export class MdChip implements MdFocusable, OnInit, OnDestroy { - /* Whether or not the chip is disabled. */ + /** Whether or not the chip is disabled. Disabled chips cannot be focused. */ protected _disabled: boolean = null; - /** - * Emitted when the chip is focused. - */ + /** Whether or not the chip is selected. */ + protected _selected: boolean = false; + + /** The palette color of selected chips. */ + protected _color: string = 'primary'; + + /** Emitted when the chip is focused. */ onFocus = new EventEmitter(); - /** - * Emitted when the chip is destroyed. - */ + /** Emitted when the chip is selected. */ + @Output() select = new EventEmitter(); + + /** Emitted when the chip is deselected. */ + @Output() deselect = new EventEmitter(); + + /** Emitted when the chip is destroyed. */ @Output() destroy = new EventEmitter(); - constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {} + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) { + } ngOnInit(): void { - let el: HTMLElement = this._elementRef.nativeElement; - - if (el.nodeName.toLowerCase() == 'md-chip' || el.hasAttribute('md-chip')) { - el.classList.add('md-chip'); - } + this._addDefaultCSSClass(); + this._updateColor(this._color); } ngOnDestroy(): void { - this.destroy.emit({ chip: this }); + this.destroy.emit({chip: this}); } /** Whether or not the chip is disabled. */ @@ -76,10 +83,40 @@ export class MdChip implements MdFocusable, OnInit, OnDestroy { return String(coerceBooleanProperty(this.disabled)); } + /** Whether or not this chip is selected. */ + @Input() get selected(): boolean { + return this._selected; + } + + set selected(value: boolean) { + this._selected = coerceBooleanProperty(value); + + if (this._selected) { + this.select.emit({chip: this}); + } else { + this.deselect.emit({chip: this}); + } + } + + /** Toggles the current selected state of this chip. */ + toggleSelected(): boolean { + this.selected = !this.selected; + return this.selected; + } + + /** The color of the chip. Can be `primary`, `accent`, or `warn`. */ + @Input() get color(): string { + return this._color; + } + + set color(value: string) { + this._updateColor(value); + } + /** Allows for programmatic focusing of the chip. */ focus(): void { this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus'); - this.onFocus.emit({ chip: this }); + this.onFocus.emit({chip: this}); } /** Ensures events fire properly upon click. */ @@ -92,4 +129,27 @@ export class MdChip implements MdFocusable, OnInit, OnDestroy { this.focus(); } } + + /** Initializes the appropriate CSS classes based on the chip type (basic or standard). */ + private _addDefaultCSSClass() { + let el: HTMLElement = this._elementRef.nativeElement; + + if (el.nodeName.toLowerCase() == 'md-chip' || el.hasAttribute('md-chip')) { + el.classList.add('md-chip'); + } + } + + /** Updates the private _color variable and the native element. */ + private _updateColor(newColor: string) { + this._setElementColor(this._color, false); + this._setElementColor(newColor, true); + this._color = newColor; + } + + /** Sets the md-color on the native element. */ + private _setElementColor(color: string, isAdd: boolean) { + if (color != null && color != '') { + this._renderer.setElementClass(this._elementRef.nativeElement, `md-${color}`, isAdd); + } + } } diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts index 2d2e14eefa53..6a75c1be87ce 100644 --- a/src/lib/core/a11y/list-key-manager.spec.ts +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -15,7 +15,7 @@ class FakeQueryList extends QueryList { } } -class FakeEvent { +export class FakeEvent { defaultPrevented: boolean = false; constructor(public keyCode: number) {} preventDefault() {