Skip to content

Commit

Permalink
Add chips keyboard support.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
topherfangio committed Dec 1, 2016
1 parent 26eb7ce commit 9183484
Show file tree
Hide file tree
Showing 14 changed files with 633 additions and 31 deletions.
64 changes: 60 additions & 4 deletions src/demo-app/chips/chips-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,67 @@
<section>
<h3>Static Chips</h3>

<h5>Simple</h5>

<md-chip-list>
<md-chip>Chip 1</md-chip>
<md-chip>Chip 2</md-chip>
<md-chip>Chip 3</md-chip>
</md-chip-list>

<h5>Advanced</h5>

<md-chip-list>
<md-chip class="md-accent selected">Selected/Colored</md-chip>
<md-chip class="md-warn" *ngIf="visible"
(destroy)="alert('chip destroyed')" (click)="toggleVisible()">
With Events
</md-chip>
</md-chip-list>

<h5>Unstyled</h5>

<md-chip-list>
<md-basic-chip>Basic Chip 1</md-basic-chip>
<md-basic-chip>Basic Chip 2</md-basic-chip>
<md-basic-chip>Basic Chip 3</md-basic-chip>
</md-chip-list>

<h3>Material Contributors</h3>

<md-chip-list>
<md-chip>Basic Chip</md-chip>
<md-chip class="selected md-primary">Primary</md-chip>
<md-chip class="selected md-accent">Accent</md-chip>
<md-chip class="selected md-warn">Warn</md-chip>
<md-chip *ngFor="let person of people; let even = even" [ngClass]="[color, even ? 'selected' : '' ]">
{{person.name}}
</md-chip>
</md-chip-list>

<br />

<md-input #input (keyup.enter)="add(input)" (blur)="add(input)" placeholder="New Contributor...">
</md-input>

<h3>Stacked Chips</h3>

<p>
You can also stack the chips if you want them on top of each other.
</p>

<md-chip-list class="md-chip-list-stacked">
<md-chip (focus)="color = ''" class="selected">
None
</md-chip>

<md-chip (focus)="color = 'md-primary'" class="selected md-primary">
Primary
</md-chip>

<md-chip (focus)="color = 'md-accent'" class="selected md-accent">
Accent
</md-chip>

<md-chip (focus)="color = 'md-warn'" class="selected md-warn">
Warn
</md-chip>
</md-chip-list>
</section>
</div>
8 changes: 8 additions & 0 deletions src/demo-app/chips/chips-demo.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
.chips-demo {
.md-chip-list-stacked {
display: block;
max-width: 200px;
}

md-basic-chip {
margin: auto 10px;
}
}
30 changes: 30 additions & 0 deletions src/demo-app/chips/chips-demo.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
import {Component} from '@angular/core';

export interface Person {
name: string;
}

@Component({
moduleId: module.id,
selector: 'chips-demo',
templateUrl: 'chips-demo.html',
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;
}
}
7 changes: 6 additions & 1 deletion src/lib/chips/_chips-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -26,4 +31,4 @@
color: md-contrast($warn, 500);
}
}
}
}
156 changes: 156 additions & 0 deletions src/lib/chips/chip-list-key-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {QueryList, Renderer, AnimationPlayer} from '@angular/core';
import {async, TestBed} from '@angular/core/testing';
import {MdBasicChip} from './index';
import {ChipListKeyManager} from './chip-list-key-manager';
import {RenderDebugInfo} from '@angular/core/src/render/api';
import {AnimationStyles} from '@angular/core/src/animation/animation_styles';
import {AnimationKeyframe} from '@angular/core/src/animation/animation_keyframe';

class FakeRenderer extends Renderer {
selectRootElement(selectorOrNode: string|any, debugInfo?: RenderDebugInfo): any {
return null;
}

createElement(parentElement: any, name: string, debugInfo?: RenderDebugInfo): any {
return null;
}

createViewRoot(hostElement: any): any {
return null;
}

createTemplateAnchor(parentElement: any, debugInfo?: RenderDebugInfo): any {
return null;
}

createText(parentElement: any, value: string, debugInfo?: RenderDebugInfo): any {
return null;
}

projectNodes(parentElement: any, nodes: any[]): void {
}

attachViewAfter(node: any, viewRootNodes: any[]): void {
}

detachView(viewRootNodes: any[]): void {
}

destroyView(hostElement: any, viewAllNodes: any[]): void {
}

listen(renderElement: any, name: string, callback: Function): Function {
return null;
}

listenGlobal(target: string, name: string, callback: Function): Function {
return null;
}

setElementProperty(renderElement: any, propertyName: string, propertyValue: any): void {
}

setElementAttribute(renderElement: any, attributeName: string, attributeValue: string): void {
}

setBindingDebugInfo(renderElement: any, propertyName: string, propertyValue: string): void {
}

setElementClass(renderElement: any, className: string, isAdd: boolean): void {
}

setElementStyle(renderElement: any, styleName: string, styleValue: string): void {
}

invokeElementMethod(renderElement: any, methodName: string, args?: any[]): void {
}

setText(renderNode: any, text: string): void {
}

animate(element: any, startingStyles: AnimationStyles, keyframes: AnimationKeyframe[],
duration: number, delay: number, easing: string,
previousPlayers?: AnimationPlayer[]): AnimationPlayer {
return null;
}

}

class FakeElement {
nativeElement: Element;
}

/*
* 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(new FakeRenderer(), new FakeElement());
}

}

describe('ChipListKeyManager', () => {
let items: QueryList<MdBasicChip>;
let manager: ChipListKeyManager;

beforeEach(async(() => {
items = new QueryList<MdBasicChip>();
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);
});
});
});
});
89 changes: 89 additions & 0 deletions src/lib/chips/chip-list-key-manager.ts
Original file line number Diff line number Diff line change
@@ -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<MdBasicChip>) {
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<MdBasicChip>) => {
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<MdBasicChip>): 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.onFocus.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;
}
}
Loading

0 comments on commit 9183484

Please sign in to comment.