From f45c31588355b29852fb5ca93fd334ce60384404 Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Tue, 20 Dec 2016 17:11:19 -0600 Subject: [PATCH] feat(chips): add (select), [color] and dark theme to chips. (#2242) 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 | 103 +++++++++---------- 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, 379 insertions(+), 114 deletions(-) diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 5100ef5f31f..d3ba62b3824 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -1,68 +1,65 @@
-
-

Static Chips

+ + Static Chips -
Simple
+ +

Simple

- - Chip 1 - Chip 2 - Chip 3 - + + Chip 1 + Chip 2 + Chip 3 + -
Advanced
+

Unstyled

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

Advanced

- - Basic Chip 1 - Basic Chip 2 - Basic Chip 3 - + + Selected/Colored + + With Events + + +
+
-

Material Contributors

+ + Dynamic Chips - - - {{person.name}} - - + +

Input Container

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

Stacked Chips

+

Stacked Chips

-

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

+

+ 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. +

- - - None - - - - Primary - - - - Accent - - - - Warn - - -
+ + + {{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 46e1d249941..f0ba734b465 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 45ac721ff49..16135f235cf 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 9fa1b60b224..3dd5dccfc8e 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 01daa7be7bd..3552f90996f 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 aebd396ac82..a37f8aab3b2 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 0b623e3bb3c..9797909d7c6 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() { + chipFocus(event: MdChipEvent) { } - chipDestroy() { + chipDestroy(event: MdChipEvent) { + } + + chipSelect(event: MdChipEvent) { + } + + chipDeselect(event: MdChipEvent) { } } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 40f7df92ef3..b87a700967a 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 Focusable, 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 Focusable, 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 Focusable, 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 2d2e14eefa5..6a75c1be87c 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() {