From 196b86c6f99de58215c868f23f87558f1870f2c6 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. Additionally, update ListKeyManager to prevent default for key presses so page does not scroll with up/down keys. 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-key-manager.spec.ts | 82 +++++++++++ src/lib/chips/chip-list-key-manager.ts | 89 +++++++++++ src/lib/chips/chip-list.ts | 60 ++++++-- src/lib/chips/chip.spec.ts | 35 ++++- src/lib/chips/chip.ts | 155 ++++++++++++++++++-- src/lib/chips/chips.scss | 72 ++++++++- src/lib/core/a11y/list-key-manager.spec.ts | 11 +- src/lib/core/a11y/list-key-manager.ts | 18 ++- src/lib/core/keyboard/keycodes.ts | 3 + 13 files changed, 601 insertions(+), 33 deletions(-) create mode 100644 src/lib/chips/chip-list-key-manager.spec.ts create mode 100644 src/lib/chips/chip-list-key-manager.ts diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html index 99f8b4ea45a3..ad4c8581776c 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..97d81fc9fc49 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' } + ]; + + static 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..b2dda3ac880b 100644 --- a/src/lib/chips/_chips-theme.scss +++ b/src/lib/chips/_chips-theme.scss @@ -7,12 +7,17 @@ $warn: map-get($theme, warn); $background: map-get($theme, background); + // TODO: Should this be in chips.scss since it is independent of theme? .md-chip { background-color: #e0e0e0; color: rgba(0, 0, 0, 0.87); } .md-chip.selected { + // TODO: Based on spec, this should be #808080, but we can only use md-contrast with a palette + background-color: md-color($md-grey, 600); + 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-key-manager.spec.ts b/src/lib/chips/chip-list-key-manager.spec.ts new file mode 100644 index 000000000000..3135557286a5 --- /dev/null +++ b/src/lib/chips/chip-list-key-manager.spec.ts @@ -0,0 +1,82 @@ +import {QueryList} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; +import {MdBasicChip} from './index'; +import {ChipListKeyManager} from './chip-list-key-manager'; + +/* + * Create a fake Chip class so we don't have to test actual HTML elements. + */ +class FakeChip extends MdBasicChip { + constructor() { + // Pass in null for the renderer/elementRef + super(null, null); + } + + // Override the required focus() method to NOT call the underlying renderer (which is null) + focus() { + this.didfocus.emit(); + } +} + +describe('ChipListKeyManager', () => { + let items: QueryList; + let manager: ChipListKeyManager; + + beforeEach(async(() => { + items = new QueryList(); + items.reset([ + new FakeChip(), + new FakeChip(), + new FakeChip(), + new FakeChip(), + new FakeChip() + ]); + + manager = new ChipListKeyManager(items); + + TestBed.compileComponents(); + })); + + describe('basic behaviors', () => { + it('watches for chip focus', () => { + let array = items.toArray(); + let lastIndex = array.length - 1; + let lastItem = array[lastIndex]; + + lastItem.focus(); + + expect(manager.focusedItemIndex).toBe(lastIndex); + }); + + 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 + midItem.destroy.emit(); + + // 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 + lastItem.destroy.emit(); + + // It focuses the next-to-last item + expect(manager.focusedItemIndex).toEqual(lastIndex - 1); + }); + }); + }); +}); diff --git a/src/lib/chips/chip-list-key-manager.ts b/src/lib/chips/chip-list-key-manager.ts new file mode 100644 index 000000000000..b23af4b7a556 --- /dev/null +++ b/src/lib/chips/chip-list-key-manager.ts @@ -0,0 +1,89 @@ +import {QueryList} from '@angular/core'; +import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {MdBasicChip} from './chip'; + +/** + * Manages keyboard events for the chip list and its chips. When instantiated + * with a QueryList of MdBasicChip (i.e. any chip), it will ensure focus and + * keyboard navigation are properly handled. + */ +export class ChipListKeyManager extends ListKeyManager { + private _subscribed: MdBasicChip[] = []; + + constructor(private _chips: QueryList) { + super(_chips); + + // 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); + }); + } + + /** + * 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: MdBasicChip) => { + 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: MdBasicChip) { + // If we've already been subscribed to a parent, do nothing + if (this._subscribed.indexOf(chip) > -1) { + return; + } + + // Watch for focus events outside of the keyboard navigation + chip.didfocus.subscribe(() => { + let chipIndex: number = this._chips.toArray().indexOf(chip); + + if (this.isValidIndex(chipIndex)) { + this.setFocus(chipIndex, false); + } + }); + + // 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.setFocus(chipIndex); + } else if (chipIndex - 1 >= 0) { + this.setFocus(chipIndex - 1); + } + } + + this._subscribed.splice(this._subscribed.indexOf(chip), 1); + chip.destroy.unsubscribe(); + }); + + this._subscribed.push(chip); + } + + /** + * 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; + } +} diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 1d7a2c778e27..7d75540534b8 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -1,38 +1,80 @@ import { ChangeDetectionStrategy, Component, + ContentChildren, ElementRef, ModuleWithProviders, NgModule, + QueryList, ViewEncapsulation } from '@angular/core'; -import {MdChip} from './chip'; +import {MdBasicChip, MdChip} from './chip'; +import {ChipListKeyManager} from './chip-list-key-manager'; -@Component({ +/** + * The Component configuration for the MdChipList component. + * + * @type Component + */ +export const MD_CHIP_LIST_COMPONENT_CONFIG: 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(MdBasicChip) }, styleUrls: ['chips.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush -}) +}; +/** + * A material design chips component (named ChipList for it's similarity to the List component). + */ +@Component(MD_CHIP_LIST_COMPONENT_CONFIG) export class MdChipList { - constructor(private _elementRef: ElementRef) {} - ngAfterContentInit(): void {} + private _keyManager: ChipListKeyManager; + + /** + * The chip components contained within this chip list. + */ + public chips: QueryList; + + constructor(private _elementRef: ElementRef) { + } + + ngAfterContentInit(): void { + this._keyManager = new ChipListKeyManager(this.chips).withFocusWrap(); + } + + + /******************** + * EVENTS + ********************/ + focus(event: Event) { + this._keyManager.focusFirstItem(); + } + + keydown(event: KeyboardEvent) { + this._keyManager.onKeydown(event); + } } @NgModule({ imports: [], - exports: [MdChipList, MdChip], - declarations: [MdChipList, MdChip] + exports: [MdChipList, MdBasicChip, MdChip], + declarations: [MdChipList, MdBasicChip, MdChip] }) export class MdChipsModule { static forRoot(): ModuleWithProviders { diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts index 15b0157012d6..92a99e7b7152 100644 --- a/src/lib/chips/chip.spec.ts +++ b/src/lib/chips/chip.spec.ts @@ -36,12 +36,43 @@ describe('MdChip', () => { it('adds the `md-chip` class', () => { expect(chipNativeElement.classList).toContain('md-chip'); }); + + it('emits didfocus on focus', () => { + spyOn(testComponent, 'chipFocus'); + + chipInstance.focus(); + + expect(testComponent.chipFocus).toHaveBeenCalledTimes(1); + }); + + it('emits didfocus on click', () => { + spyOn(testComponent, 'chipFocus'); + + chipNativeElement.click(); + + expect(testComponent.chipFocus).toHaveBeenCalledTimes(1); + }); + + it('emits destroy on destruction', () => { + spyOn(testComponent, 'chipDestroy'); + + // Fake a destroy callback + chipInstance.ngOnDestroy(); + + expect(testComponent.chipDestroy).toHaveBeenCalledTimes(1); + }); }); }); @Component({ - template: `{{name}}` + template: ` + + {{name}} + ` }) class SingleChip { - name: 'Test'; + name: String = 'Test'; + + chipFocus() {} + chipDestroy() {} } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 461834f05266..ebfc9654e72c 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -1,17 +1,154 @@ -import { Component, ElementRef, Renderer } from '@angular/core'; +import { + // Classes + AfterViewInit, + Component, + ElementRef, + EventEmitter, + OnDestroy, + Renderer, -@Component({ - selector: 'md-chip, [md-chip]', + // Functions + forwardRef +} from '@angular/core'; + +import {MdFocusable} from '../core/a11y/list-key-manager'; + +/* + * A reusable component config. Utilized by the MdBasicChip + * as well as the child class MdChip. + */ +export const MD_BASIC_CHIP_COMPONENT_CONFIG: Component = { + selector: 'md-basic-chip, [md-basic-chip]', template: ``, host: { // Properties - 'class': 'md-chip', 'tabindex': '-1', - 'role': 'option' - } -}) -export class MdChip { + 'role': 'option', + + // Attributes + '[attr.disabled]': 'disabled', + '[attr.aria-disabled]': 'isAriaDisabled', + + // Events + '(click)': 'click($event)', + }, + inputs: ['disabled'], + outputs: ['didfocus', 'destroy'] +}; + +/** + * The base, unstyled chip component extended by the styled MdChip and any custom chips. + */ +@Component(MD_BASIC_CHIP_COMPONENT_CONFIG) +export class MdBasicChip implements MdFocusable, OnDestroy { + + /* Whether or not the chip is disabled. */ + protected _disabled: boolean = false; + + /** + * Emitted when the chip receives focus. + * @type {EventEmitter} + */ + public didfocus = new EventEmitter(); + + /** + * Emitted when the chip is destroyed. + * @type {EventEmitter} + */ + public destroy = new EventEmitter(); + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {} - ngAfterContentInit(): void {} + ngOnDestroy(): void { + this.destroy.emit({chip: this}); + } + + /** + * Whether or not the chip is disabled. + * + * @returns {boolean} + */ + get disabled(): boolean { + return this._disabled; + } + + /** + * Sets the disabled state of the chip. + * + * @param value `true` to disable, `false` to enable. + */ + set disabled(value: boolean) { + this._disabled = (value === false || value === undefined) ? null : true; + } + + /** + * A String representation of the current disabled state. + * + * @returns {String} + */ + get isAriaDisabled(): string { + return String(this.disabled); + } + + /** + * Allows for programmatic focusing of the chip. Emits the `didfocus` event for the MdChipList to + * consume and forward as appropriate. + */ + focus(): void { + this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus'); + this.didfocus.emit({chip: this}); + } + + /** + * Ensures events fire properly upon click. + * + * @param event + */ + click(event: Event) { + // No matter what, we should emit the didfocus event + this.didfocus.emit({chip: this}); + + // Check disabled + if (this.disabled) { + event.preventDefault(); + event.stopPropagation(); + } + } +} + +/** + * The Component configuration for the MdChip component. Extends and utilizes the + * MD_BASIC_CHIP_COMPONENT_CONFIG. + * + * @type Component + */ +export const MD_CHIP_COMPONENT_CONFIG: Component = { + // Defaults imported from parent + template: MD_BASIC_CHIP_COMPONENT_CONFIG.template, + host: MD_BASIC_CHIP_COMPONENT_CONFIG.host, + inputs: MD_BASIC_CHIP_COMPONENT_CONFIG.inputs, + outputs: MD_BASIC_CHIP_COMPONENT_CONFIG.outputs, + + // Overrides + selector: 'md-chip, [md-chip]', + + // Provider to forward MdChip to MdBasicChip + providers: [{provide: MdBasicChip, useExisting: forwardRef(() => MdChip)}] +}; + +/** + * A material design styled Chip component. Used inside the ChipList component. + */ +@Component(MD_CHIP_COMPONENT_CONFIG) +export class MdChip extends MdBasicChip implements AfterViewInit { + + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) { + super(_renderer, _elementRef); + } + + ngAfterViewInit(): void { + // Temporary host workaround until ng2 supports component extension with decorators + this._elementRef.nativeElement.classList.add('md-chip'); + } + } 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..e9b7b36bfecd 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 setFocus() without focusing the element', () => { + expect(keyManager.focusedItemIndex) + .toBe(0, `Expected focus to be on the first item of the list.`); + + keyManager.setFocus(1, false); + expect(keyManager.focusedItemIndex) + .toBe(1, `Expected focusedItemIndex to be updated when setFocus() was called.`); + 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..40d5665c8ecf 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -32,10 +32,18 @@ export class ListKeyManager { return this; } - /** Sets the focus of the list to the item at the index specified. */ - setFocus(index: number): void { + /** + * Sets the focus of the list to the item at the index specified. + * + * @param index The index of the item to be focused. + * @param focusElement Whether or not to focus the element as well. Defaults to `true`. + */ + setFocus(index: number, focusElement = true): void { this._focusedItemIndex = index; - this._items.toArray()[index].focus(); + + if (focusElement) { + this._items.toArray()[index].focus(); + } } /** Sets the focus properly depending on the key event passed in. */ @@ -43,15 +51,19 @@ export class ListKeyManager { switch (event.keyCode) { case DOWN_ARROW: this.focusNextItem(); + event.preventDefault(); break; case UP_ARROW: this.focusPreviousItem(); + event.preventDefault(); break; case HOME: this.focusFirstItem(); + event.preventDefault(); break; case END: this.focusLastItem(); + event.preventDefault(); break; case TAB: // Note that we shouldn't prevent the default action on tab. diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts index 6204987a0e8a..ab287296e9c8 100644 --- a/src/lib/core/keyboard/keycodes.ts +++ b/src/lib/core/keyboard/keycodes.ts @@ -18,3 +18,6 @@ export const END = 35; export const ENTER = 13; export const SPACE = 32; export const TAB = 9; + +export const BACKSPACE = 8; +export const DELETE = 46;