From 7b61ffe027135b5a6015f4c40201510ae81928c1 Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Mon, 7 Nov 2016 02:06:38 -0600 Subject: [PATCH] feat(chips): Add (select), [color] and dark theme to chips. Add new functionality/options to chips for managing selection/color. *MdChipList Options* - `[selectable]` - Programmatically control whether or not the chips in the list are capable of being selected. - The SPACE key will automatically select the currently focused chip. *MdChip Options* - `[color]` - Programmatically control the selected color of the chip. - `[selected]` - Programmatically control whether or not the chip is selected. - `(select)` - Event emitted when the chip is selected. - `(deselect)` - Event emitted when the chip is deselected. Additionally, adds basic support for dark themeed chips using existing colors from other components in the spec and cleanup demos by using cards and toolbars like other demos. References #120. --- src/demo-app/chips/chips-demo.html | 129 ++++++++++----------- src/demo-app/chips/chips-demo.scss | 13 +++ src/demo-app/chips/chips-demo.ts | 12 ++ src/lib/chips/_chips-theme.scss | 30 +++-- src/lib/chips/chip-list.spec.ts | 112 +++++++++++++++--- src/lib/chips/chip-list.ts | 85 +++++++++++--- src/lib/chips/chip.spec.ts | 44 ++++++- src/lib/chips/chip.ts | 92 ++++++++++++--- src/lib/core/a11y/list-key-manager.spec.ts | 2 +- 9 files changed, 392 insertions(+), 127 deletions(-) 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() {