From 7ee819c5f507ac66483a88326ed04d83838c7627 Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Mon, 7 Nov 2016 02:06:38 -0600 Subject: [PATCH] Add chips keyboard support. Add basic focus/keyboard support for chips. - Up/down arrows navigate chips. - Clicking a chip properly focuses it for subsequent keyboard navigation. - More demos. Confirmed AoT compatibility. References #120. --- src/demo-app/chips/chips-demo.html | 64 ++++++++++- src/demo-app/chips/chips-demo.scss | 8 ++ src/demo-app/chips/chips-demo.ts | 30 +++++ src/lib/chips/_chips-theme.scss | 7 +- src/lib/chips/chip-list.spec.ts | 93 +++++++++++---- src/lib/chips/chip-list.ts | 125 ++++++++++++++++++++- src/lib/chips/chip.spec.ts | 99 +++++++++++++--- src/lib/chips/chip.ts | 93 +++++++++++++-- src/lib/chips/chips.scss | 72 +++++++++++- src/lib/core/a11y/list-key-manager.spec.ts | 11 +- src/lib/core/a11y/list-key-manager.ts | 11 +- src/lib/core/keyboard/keycodes.ts | 2 + test/karma.config.ts | 2 +- 13 files changed, 561 insertions(+), 56 deletions(-) diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 99f8b4ea45a3..5100ef5f31fc 100644 --- a/src/demo-app/chips/chips-demo.html +++ b/src/demo-app/chips/chips-demo.html @@ -2,11 +2,67 @@

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

+ - Basic Chip - Primary - Accent - Warn + + {{person.name}} + + + +
+ + + + +

Stacked Chips

+ +

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

+ + + + None + + + + Primary + + + + Accent + + + + Warn +
\ 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 996d6cfd641f..46e1d249941e 100644 --- a/src/demo-app/chips/chips-demo.scss +++ b/src/demo-app/chips/chips-demo.scss @@ -1,2 +1,10 @@ .chips-demo { + .md-chip-list-stacked { + display: block; + max-width: 200px; + } + + md-basic-chip { + margin: auto 10px; + } } \ 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 ea2693f24cf2..84756cbcf22b 100644 --- a/src/demo-app/chips/chips-demo.ts +++ b/src/demo-app/chips/chips-demo.ts @@ -1,5 +1,9 @@ import {Component} from '@angular/core'; +export interface Person { + name: string; +} + @Component({ moduleId: module.id, selector: 'chips-demo', @@ -7,4 +11,30 @@ import {Component} from '@angular/core'; styleUrls: ['chips-demo.css'] }) export class ChipsDemo { + visible: boolean = true; + color: string = ''; + + people: Person[] = [ + { name: 'Kara' }, + { name: 'Jeremy' }, + { name: 'Topher' }, + { name: 'Elad' }, + { name: 'Kristiyan' }, + { name: 'Paul' } + ]; + + alert(message: string): void { + alert(message); + } + + add(input: HTMLInputElement): void { + if (input.value && input.value.trim() != '') { + this.people.push({ name: input.value.trim() }); + input.value = ''; + } + } + + toggleVisible(): void { + this.visible = false; + } } diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss index 5f0826096c76..e754e2376433 100644 --- a/src/lib/chips/_chips-theme.scss +++ b/src/lib/chips/_chips-theme.scss @@ -13,6 +13,11 @@ } .md-chip.selected { + background-color: #808080; + + // Use a contrast color for a grey very close to the background color + color: md-contrast($md-grey, 600); + &.md-primary { background-color: md-color($primary, 500); color: md-contrast($primary, 500); @@ -26,4 +31,4 @@ color: md-contrast($warn, 500); } } -} \ No newline at end of file +} diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index 14c1b69fa674..30f67b7850b9 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -1,10 +1,17 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Component, DebugElement} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {MdChipList, MdChipsModule} from './index'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +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'; -describe('MdChip', () => { +describe('MdChipList', () => { let fixture: ComponentFixture; + let chipListDebugElement: DebugElement; + let chipListNativeElement: HTMLElement; + let chipListInstance: MdChipList; + let testComponent: StaticChipList; + let items: QueryList; + let manager: ListKeyManager; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -15,39 +22,87 @@ describe('MdChip', () => { }); TestBed.compileComponents(); + + fixture = TestBed.createComponent(StaticChipList); + fixture.detectChanges(); + + chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList)); + chipListNativeElement = chipListDebugElement.nativeElement; + chipListInstance = chipListDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; })); describe('basic behaviors', () => { - let chipListDebugElement: DebugElement; - let chipListNativeElement: HTMLElement; - let chipListInstance: MdChipList; - let testComponent: StaticChipList; + it('adds the `md-chip-list` class', () => { + expect(chipListNativeElement.classList).toContain('md-chip-list'); + }); + }); + describe('focus behaviors', () => { beforeEach(() => { - fixture = TestBed.createComponent(StaticChipList); + items = chipListInstance.chips; + manager = chipListInstance._keyManager; + }); + + it('watches for chip focus', () => { + let array = items.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + lastItem.focus(); fixture.detectChanges(); - chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList)); - chipListNativeElement = chipListDebugElement.nativeElement; - chipListInstance = chipListDebugElement.componentInstance; - testComponent = fixture.debugElement.componentInstance; + expect(manager.focusedItemIndex).toBe(lastIndex); }); - it('adds the `md-chip-list` class', () => { - expect(chipListNativeElement.classList).toContain('md-chip-list'); + describe('on chip destroy', () => { + it('focuses the next item', () => { + let array = items.toArray(); + let midItem = array[2]; + + // 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.focusedItemIndex).toEqual(2); + }); + + it('focuses the previous item', () => { + let array = items.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.focusedItemIndex).toEqual(lastIndex - 1); + }); }); }); + }); @Component({ template: ` - {{name}} 1 - {{name}} 2 - {{name}} 3 +
{{name}} 1
+
{{name}} 2
+
{{name}} 3
+
{{name}} 4
+
{{name}} 5
` }) class StaticChipList { name: 'Test'; + remove: Number; } diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 1d7a2c778e27..f925c0a0443e 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -1,32 +1,149 @@ import { + AfterContentInit, ChangeDetectionStrategy, Component, + ContentChildren, ElementRef, ModuleWithProviders, NgModule, + QueryList, ViewEncapsulation } from '@angular/core'; import {MdChip} from './chip'; +import {ListKeyManager} from '../core/a11y/list-key-manager'; +/** + * A material design chips component (named ChipList for it's similarity to the List component). + * + * Example: + * + * + * Chip 1 + * Chip 2 + * + */ @Component({ moduleId: module.id, selector: 'md-chip-list', - template: ``, + template: `
`, host: { // Properties 'tabindex': '0', 'role': 'listbox', - 'class': 'md-chip-list' + 'class': 'md-chip-list', + + // Events + '(focus)': 'focus($event)', + '(keydown)': 'keydown($event)' + }, + queries: { + chips: new ContentChildren(MdChip) }, styleUrls: ['chips.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) -export class MdChipList { +export class MdChipList implements AfterContentInit { + + /** Track which chips we're listening to for focus/destruction. */ + private _subscribed: WeakMap = new WeakMap(); + + /** The ListKeyManager which handles focus. */ + _keyManager: ListKeyManager; + + /** The chip components contained within this chip list. */ + chips: QueryList; + constructor(private _elementRef: ElementRef) {} - ngAfterContentInit(): void {} + ngAfterContentInit(): void { + this._keyManager = new ListKeyManager(this.chips).withFocusWrap(); + + // Go ahead and subscribe all of the initial chips + this.subscribeChips(this.chips); + + // When the list changes, re-subscribe + this.chips.changes.subscribe((chips: QueryList) => { + this.subscribeChips(chips); + }); + } + + /** Pass relevant focus events to our key manager. */ + focus(event: Event) { + this._keyManager.focusFirstItem(); + } + + /** Pass relevant key presses to our key manager. */ + keydown(event: KeyboardEvent) { + this._keyManager.onKeydown(event); + } + + /** + * Iterate through the list of chips and add them to our list of + * subscribed chips. + * + * @param chips The list of chips to be subscribed. + */ + protected subscribeChips(chips: QueryList): void { + chips.forEach((chip: MdChip) => { + this.addChip(chip); + }); + } + + /** + * Add a specific chip to our subscribed list. If the chip has + * already been subscribed, this ensures it is only subscribed + * once. + * + * @param chip The chip to be subscribed (or checked for existing + * subscription). + */ + protected addChip(chip: MdChip) { + // If we've already been subscribed to a parent, do nothing + if (this._subscribed.has(chip)) { + return; + } + + // Watch for focus events outside of the keyboard navigation + chip.onFocus.subscribe(() => { + let chipIndex: number = this.chips.toArray().indexOf(chip); + + if (this.isValidIndex(chipIndex)) { + this._keyManager.updateFocusedItemIndex(chipIndex); + } + }); + + // On destroy, remove the item from our list, and check focus + 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.setFocus(chipIndex); + } else if (chipIndex - 1 >= 0) { + this._keyManager.setFocus(chipIndex - 1); + } + } + + this._subscribed.delete(chip); + chip.destroy.unsubscribe(); + }); + + this._subscribed.set(chip, true); + } + + /** + * Utility to ensure all indexes are valid. + * + * @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 { + return index >= 0 && index < this.chips.length; + } + } @NgModule({ diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts index 15b0157012d6..0b623e3bb3ce 100644 --- a/src/lib/chips/chip.spec.ts +++ b/src/lib/chips/chip.spec.ts @@ -1,47 +1,118 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; -import {MdChip, MdChipsModule} from './index'; +import {MdChipList, MdChip, MdChipsModule} from './index'; -describe('MdChip', () => { +describe('Chips', () => { let fixture: ComponentFixture; + let chipDebugElement: DebugElement; + let chipListNativeElement: HTMLElement; + let chipNativeElement: HTMLElement; + let chipInstance: MdChip; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MdChipsModule.forRoot()], declarations: [ - SingleChip + BasicChip, SingleChip ] }); TestBed.compileComponents(); })); - describe('basic behaviors', () => { - let chipDebugElement: DebugElement; - let chipNativeElement: HTMLElement; - let chipInstance: MdChip; - let testComponent: SingleChip; + describe('MdBasicChip', () => { beforeEach(() => { - fixture = TestBed.createComponent(SingleChip); + fixture = TestBed.createComponent(BasicChip); fixture.detectChanges(); chipDebugElement = fixture.debugElement.query(By.directive(MdChip)); chipNativeElement = chipDebugElement.nativeElement; chipInstance = chipDebugElement.componentInstance; - testComponent = fixture.debugElement.componentInstance; + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); }); - it('adds the `md-chip` class', () => { - expect(chipNativeElement.classList).toContain('md-chip'); + it('does not add the `md-chip` class', () => { + expect(chipNativeElement.classList).not.toContain('md-chip'); + }); + }); + + describe('MdChip', () => { + let testComponent: SingleChip; + + describe('basic behaviors', () => { + + 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; + + document.body.appendChild(chipNativeElement); + }); + + afterEach(() => { + document.body.removeChild(chipNativeElement); + }); + + it('adds the `md-chip` class', () => { + expect(chipNativeElement.classList).toContain('md-chip'); + }); + + it('emits focus on click', () => { + spyOn(chipInstance, 'focus').and.callThrough(); + + chipNativeElement.click(); + + expect(chipInstance.focus).toHaveBeenCalledTimes(1); + }); + + it('emits destroy on destruction', () => { + spyOn(testComponent, 'chipDestroy').and.callThrough(); + + // Force a destroy callback + testComponent.shouldShow = false; + fixture.detectChanges(); + + expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); + }); }); }); }); @Component({ - template: `{{name}}` + template: ` + +
+ + {{name}} + +
+
` }) class SingleChip { - name: 'Test'; + name: String = 'Test'; + shouldShow: Boolean = true; + + chipFocus() { + } + + chipDestroy() { + } +} + +@Component({ + template: `{{name}}` +}) +class BasicChip { } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 461834f05266..c1581ebf01ce 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -1,17 +1,96 @@ -import { Component, ElementRef, Renderer } from '@angular/core'; +import { + // Classes + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + Renderer +} from '@angular/core'; +import {MdFocusable} from '../core/a11y/list-key-manager'; +import {coerceBooleanProperty} from '../core/coersion/boolean-property'; + +export interface MdChipEvent { + chip: MdChip; +} + +/** + * A material design styled Chip component. Used inside the ChipList component. + */ @Component({ - selector: 'md-chip, [md-chip]', + selector: 'md-basic-chip, [md-basic-chip], md-chip, [md-chip]', template: ``, host: { - // Properties - 'class': 'md-chip', 'tabindex': '-1', - 'role': 'option' + 'role': 'option', + + '[attr.disabled]': 'disabled', + '[attr.aria-disabled]': '_isAriaDisabled', + + '(click)': '_handleClick($event)' } }) -export class MdChip { +export class MdChip implements MdFocusable, OnInit, OnDestroy { + + /* Whether or not the chip is disabled. */ + protected _disabled: boolean = null; + + /** + * Emitted when the chip is focused. + */ + onFocus = new EventEmitter(); + + /** + * Emitted when the chip is destroyed. + */ + @Output() destroy = new EventEmitter(); + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {} - ngAfterContentInit(): void {} + ngOnInit(): void { + let el: HTMLElement = this._elementRef.nativeElement; + + if (el.nodeName.toLowerCase() == 'md-chip' || el.hasAttribute('md-chip')) { + el.classList.add('md-chip'); + } + } + + ngOnDestroy(): void { + this.destroy.emit({ chip: this }); + } + + /** Whether or not the chip is disabled. */ + @Input() get disabled(): boolean { + return this._disabled; + } + + /** Sets the disabled state of the chip. */ + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value) ? true : null; + } + + /** A String representation of the current disabled state. */ + get _isAriaDisabled(): string { + return String(coerceBooleanProperty(this.disabled)); + } + + /** Allows for programmatic focusing of the chip. */ + focus(): void { + this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus'); + this.onFocus.emit({ chip: this }); + } + + /** Ensures events fire properly upon click. */ + _handleClick(event: Event) { + // Check disabled + if (this.disabled) { + event.preventDefault(); + event.stopPropagation(); + } else { + this.focus(); + } + } } diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss index 7fa57fd207f8..379277e9daab 100644 --- a/src/lib/chips/chips.scss +++ b/src/lib/chips/chips.scss @@ -1,8 +1,53 @@ $md-chip-vertical-padding: 8px; $md-chip-horizontal-padding: 12px; +$md-chip-font-size: 13px; +$md-chip-line-height: 16px; -.md-chip-list { - padding: $md-chip-horizontal-padding; +$md-chips-chip-margin: $md-chip-horizontal-padding / 4; + +.md-chip-list-wrapper { + + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + + /* + * Only apply the margins to chips + */ + .md-chip { + margin: 0 $md-chips-chip-margin 0 $md-chips-chip-margin; + + // Remove the margin from the first element (in both LTR and RTL) + &:first-child { + margin: { + left: 0; + right: $md-chips-chip-margin; + } + + [dir='rtl'] & { + margin: { + left: $md-chips-chip-margin; + right: 0; + } + } + } + + // Remove the margin from the last element (in both LTR and RTL) + &:last-child { + margin: { + left: $md-chips-chip-margin; + right: 0; + } + + [dir='rtl'] & { + margin: { + left: 0; + right: $md-chips-chip-margin; + } + } + } + } } .md-chip { @@ -11,6 +56,25 @@ $md-chip-horizontal-padding: 12px; $md-chip-vertical-padding $md-chip-horizontal-padding; border-radius: $md-chip-horizontal-padding * 2; - font-size: 13px; - line-height: 16px; + font-size: $md-chip-font-size; + line-height: $md-chip-line-height; +} + +.md-chip-list-stacked .md-chip-list-wrapper { + display: block; + + .md-chip { + display: block; + margin: 0; + margin-bottom: $md-chip-vertical-padding; + + [dir='rtl'] & { + margin: 0; + margin-bottom: $md-chip-vertical-padding; + } + + &:last-child, [dir='rtl'] &:last-child { + margin-bottom: 0; + } + } } diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts index a8381aa2a72b..2d2e14eefa53 100644 --- a/src/lib/core/a11y/list-key-manager.spec.ts +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -23,7 +23,6 @@ class FakeEvent { } } - describe('ListKeyManager', () => { let keyManager: ListKeyManager; let itemList: FakeQueryList; @@ -210,6 +209,16 @@ describe('ListKeyManager', () => { expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); }); + it('should allow setting the focused item without calling focus', () => { + expect(keyManager.focusedItemIndex) + .toBe(0, `Expected focus to be on the first item of the list.`); + + keyManager.updateFocusedItemIndex(1); + expect(keyManager.focusedItemIndex) + .toBe(1, `Expected focusedItemIndex to be updated after calling updateFocusedItemIndex().`); + expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1); + }); + it('should focus the first item when focusFirstItem() is called', () => { keyManager.onKeydown(DOWN_ARROW_EVENT); keyManager.onKeydown(DOWN_ARROW_EVENT); diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts index 3be6819a31c2..9cca50160fa3 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -32,7 +32,11 @@ export class ListKeyManager { return this; } - /** Sets the focus of the list to the item at the index specified. */ + /** + * Sets the focus of the list to the item at the index specified. + * + * @param index The index of the item to be focused. + */ setFocus(index: number): void { this._focusedItemIndex = index; this._items.toArray()[index].focus(); @@ -89,6 +93,11 @@ export class ListKeyManager { return this._focusedItemIndex; } + /** Allows setting of the focusedItemIndex without focusing the item. */ + updateFocusedItemIndex(index: number) { + this._focusedItemIndex = index; + } + /** * Observable that emits any time the TAB key is pressed, so components can react * when focus is shifted off of the list. diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts index af4c4b702f9a..bff519c5d38f 100644 --- a/src/lib/core/keyboard/keycodes.ts +++ b/src/lib/core/keyboard/keycodes.ts @@ -20,3 +20,5 @@ export const SPACE = 32; export const TAB = 9; export const ESCAPE = 27; +export const BACKSPACE = 8; +export const DELETE = 46; diff --git a/test/karma.config.ts b/test/karma.config.ts index 78d1452808ff..6112a46c04bb 100644 --- a/test/karma.config.ts +++ b/test/karma.config.ts @@ -16,7 +16,7 @@ export function config(config) { require('karma-browserstack-launcher'), require('karma-sauce-launcher'), require('karma-chrome-launcher'), - require('karma-firefox-launcher'), + require('karma-firefox-launcher') ], files: [ {pattern: 'dist/vendor/core-js/client/core.js', included: true, watched: false},