Skip to content

Commit 9183484

Browse files
committed
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.
1 parent 26eb7ce commit 9183484

14 files changed

+633
-31
lines changed

src/demo-app/chips/chips-demo.html

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,67 @@
22
<section>
33
<h3>Static Chips</h3>
44

5+
<h5>Simple</h5>
6+
7+
<md-chip-list>
8+
<md-chip>Chip 1</md-chip>
9+
<md-chip>Chip 2</md-chip>
10+
<md-chip>Chip 3</md-chip>
11+
</md-chip-list>
12+
13+
<h5>Advanced</h5>
14+
15+
<md-chip-list>
16+
<md-chip class="md-accent selected">Selected/Colored</md-chip>
17+
<md-chip class="md-warn" *ngIf="visible"
18+
(destroy)="alert('chip destroyed')" (click)="toggleVisible()">
19+
With Events
20+
</md-chip>
21+
</md-chip-list>
22+
23+
<h5>Unstyled</h5>
24+
25+
<md-chip-list>
26+
<md-basic-chip>Basic Chip 1</md-basic-chip>
27+
<md-basic-chip>Basic Chip 2</md-basic-chip>
28+
<md-basic-chip>Basic Chip 3</md-basic-chip>
29+
</md-chip-list>
30+
31+
<h3>Material Contributors</h3>
32+
533
<md-chip-list>
6-
<md-chip>Basic Chip</md-chip>
7-
<md-chip class="selected md-primary">Primary</md-chip>
8-
<md-chip class="selected md-accent">Accent</md-chip>
9-
<md-chip class="selected md-warn">Warn</md-chip>
34+
<md-chip *ngFor="let person of people; let even = even" [ngClass]="[color, even ? 'selected' : '' ]">
35+
{{person.name}}
36+
</md-chip>
37+
</md-chip-list>
38+
39+
<br />
40+
41+
<md-input #input (keyup.enter)="add(input)" (blur)="add(input)" placeholder="New Contributor...">
42+
</md-input>
43+
44+
<h3>Stacked Chips</h3>
45+
46+
<p>
47+
You can also stack the chips if you want them on top of each other.
48+
</p>
49+
50+
<md-chip-list class="md-chip-list-stacked">
51+
<md-chip (focus)="color = ''" class="selected">
52+
None
53+
</md-chip>
54+
55+
<md-chip (focus)="color = 'md-primary'" class="selected md-primary">
56+
Primary
57+
</md-chip>
58+
59+
<md-chip (focus)="color = 'md-accent'" class="selected md-accent">
60+
Accent
61+
</md-chip>
62+
63+
<md-chip (focus)="color = 'md-warn'" class="selected md-warn">
64+
Warn
65+
</md-chip>
1066
</md-chip-list>
1167
</section>
1268
</div>

src/demo-app/chips/chips-demo.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
11
.chips-demo {
2+
.md-chip-list-stacked {
3+
display: block;
4+
max-width: 200px;
5+
}
6+
7+
md-basic-chip {
8+
margin: auto 10px;
9+
}
210
}

src/demo-app/chips/chips-demo.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
import {Component} from '@angular/core';
22

3+
export interface Person {
4+
name: string;
5+
}
6+
37
@Component({
48
moduleId: module.id,
59
selector: 'chips-demo',
610
templateUrl: 'chips-demo.html',
711
styleUrls: ['chips-demo.css']
812
})
913
export class ChipsDemo {
14+
visible: boolean = true;
15+
color: string = '';
16+
17+
people: Person[] = [
18+
{ name: 'Kara' },
19+
{ name: 'Jeremy' },
20+
{ name: 'Topher' },
21+
{ name: 'Elad' },
22+
{ name: 'Kristiyan' },
23+
{ name: 'Paul' }
24+
];
25+
26+
alert(message: string): void {
27+
alert(message);
28+
}
29+
30+
add(input: HTMLInputElement): void {
31+
if (input.value && input.value.trim() != '') {
32+
this.people.push({ name: input.value.trim() });
33+
input.value = '';
34+
}
35+
}
36+
37+
toggleVisible(): void {
38+
this.visible = false;
39+
}
1040
}

src/lib/chips/_chips-theme.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
$warn: map-get($theme, warn);
88
$background: map-get($theme, background);
99

10+
// TODO: Should this be in chips.scss since it is independent of theme?
1011
.md-chip {
1112
background-color: #e0e0e0;
1213
color: rgba(0, 0, 0, 0.87);
1314
}
1415

1516
.md-chip.selected {
17+
// TODO: Based on spec, this should be #808080, but we can only use md-contrast with a palette
18+
background-color: md-color($md-grey, 600);
19+
color: md-contrast($md-grey, 600);
20+
1621
&.md-primary {
1722
background-color: md-color($primary, 500);
1823
color: md-contrast($primary, 500);
@@ -26,4 +31,4 @@
2631
color: md-contrast($warn, 500);
2732
}
2833
}
29-
}
34+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {QueryList, Renderer, AnimationPlayer} from '@angular/core';
2+
import {async, TestBed} from '@angular/core/testing';
3+
import {MdBasicChip} from './index';
4+
import {ChipListKeyManager} from './chip-list-key-manager';
5+
import {RenderDebugInfo} from '@angular/core/src/render/api';
6+
import {AnimationStyles} from '@angular/core/src/animation/animation_styles';
7+
import {AnimationKeyframe} from '@angular/core/src/animation/animation_keyframe';
8+
9+
class FakeRenderer extends Renderer {
10+
selectRootElement(selectorOrNode: string|any, debugInfo?: RenderDebugInfo): any {
11+
return null;
12+
}
13+
14+
createElement(parentElement: any, name: string, debugInfo?: RenderDebugInfo): any {
15+
return null;
16+
}
17+
18+
createViewRoot(hostElement: any): any {
19+
return null;
20+
}
21+
22+
createTemplateAnchor(parentElement: any, debugInfo?: RenderDebugInfo): any {
23+
return null;
24+
}
25+
26+
createText(parentElement: any, value: string, debugInfo?: RenderDebugInfo): any {
27+
return null;
28+
}
29+
30+
projectNodes(parentElement: any, nodes: any[]): void {
31+
}
32+
33+
attachViewAfter(node: any, viewRootNodes: any[]): void {
34+
}
35+
36+
detachView(viewRootNodes: any[]): void {
37+
}
38+
39+
destroyView(hostElement: any, viewAllNodes: any[]): void {
40+
}
41+
42+
listen(renderElement: any, name: string, callback: Function): Function {
43+
return null;
44+
}
45+
46+
listenGlobal(target: string, name: string, callback: Function): Function {
47+
return null;
48+
}
49+
50+
setElementProperty(renderElement: any, propertyName: string, propertyValue: any): void {
51+
}
52+
53+
setElementAttribute(renderElement: any, attributeName: string, attributeValue: string): void {
54+
}
55+
56+
setBindingDebugInfo(renderElement: any, propertyName: string, propertyValue: string): void {
57+
}
58+
59+
setElementClass(renderElement: any, className: string, isAdd: boolean): void {
60+
}
61+
62+
setElementStyle(renderElement: any, styleName: string, styleValue: string): void {
63+
}
64+
65+
invokeElementMethod(renderElement: any, methodName: string, args?: any[]): void {
66+
}
67+
68+
setText(renderNode: any, text: string): void {
69+
}
70+
71+
animate(element: any, startingStyles: AnimationStyles, keyframes: AnimationKeyframe[],
72+
duration: number, delay: number, easing: string,
73+
previousPlayers?: AnimationPlayer[]): AnimationPlayer {
74+
return null;
75+
}
76+
77+
}
78+
79+
class FakeElement {
80+
nativeElement: Element;
81+
}
82+
83+
/*
84+
* Create a fake Chip class so we don't have to test actual HTML elements.
85+
*/
86+
class FakeChip extends MdBasicChip {
87+
88+
constructor() {
89+
// Pass in null for the renderer/elementRef
90+
super(new FakeRenderer(), new FakeElement());
91+
}
92+
93+
}
94+
95+
describe('ChipListKeyManager', () => {
96+
let items: QueryList<MdBasicChip>;
97+
let manager: ChipListKeyManager;
98+
99+
beforeEach(async(() => {
100+
items = new QueryList<MdBasicChip>();
101+
items.reset([
102+
new FakeChip(),
103+
new FakeChip(),
104+
new FakeChip(),
105+
new FakeChip(),
106+
new FakeChip()
107+
]);
108+
109+
manager = new ChipListKeyManager(items);
110+
111+
TestBed.compileComponents();
112+
}));
113+
114+
describe('basic behaviors', () => {
115+
it('watches for chip focus', () => {
116+
let array = items.toArray();
117+
let lastIndex = array.length - 1;
118+
let lastItem = array[lastIndex];
119+
120+
lastItem.focus();
121+
122+
expect(manager.focusedItemIndex).toBe(lastIndex);
123+
});
124+
125+
describe('on chip destroy', () => {
126+
it('focuses the next item', () => {
127+
let array = items.toArray();
128+
let midItem = array[2];
129+
130+
// Focus the middle item
131+
midItem.focus();
132+
133+
// Destroy the middle item
134+
midItem.destroy.emit();
135+
136+
// It focuses the 4th item (now at index 2)
137+
expect(manager.focusedItemIndex).toEqual(2);
138+
});
139+
140+
it('focuses the previous item', () => {
141+
let array = items.toArray();
142+
let lastIndex = array.length - 1;
143+
let lastItem = array[lastIndex];
144+
145+
// Focus the last item
146+
lastItem.focus();
147+
148+
// Destroy the last item
149+
lastItem.destroy.emit();
150+
151+
// It focuses the next-to-last item
152+
expect(manager.focusedItemIndex).toEqual(lastIndex - 1);
153+
});
154+
});
155+
});
156+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {QueryList} from '@angular/core';
2+
import {ListKeyManager} from '../core/a11y/list-key-manager';
3+
import {MdBasicChip} from './chip';
4+
5+
/**
6+
* Manages keyboard events for the chip list and its chips. When instantiated
7+
* with a QueryList of MdBasicChip (i.e. any chip), it will ensure focus and
8+
* keyboard navigation are properly handled.
9+
*/
10+
export class ChipListKeyManager extends ListKeyManager {
11+
private _subscribed: MdBasicChip[] = [];
12+
13+
constructor(private _chips: QueryList<MdBasicChip>) {
14+
super(_chips);
15+
16+
// Go ahead and subscribe all of the initial chips
17+
this.subscribeChips(this._chips);
18+
19+
// When the list changes, re-subscribe
20+
this._chips.changes.subscribe((chips: QueryList<MdBasicChip>) => {
21+
this.subscribeChips(chips);
22+
});
23+
}
24+
25+
/**
26+
* Iterate through the list of chips and add them to our list of
27+
* subscribed chips.
28+
*
29+
* @param chips The list of chips to be subscribed.
30+
*/
31+
protected subscribeChips(chips: QueryList<MdBasicChip>): void {
32+
chips.forEach((chip: MdBasicChip) => {
33+
this.addChip(chip);
34+
});
35+
}
36+
37+
/**
38+
* Add a specific chip to our subscribed list. If the chip has
39+
* already been subscribed, this ensures it is only subscribed
40+
* once.
41+
*
42+
* @param chip The chip to be subscribed (or checked for existing
43+
* subscription).
44+
*/
45+
protected addChip(chip: MdBasicChip) {
46+
// If we've already been subscribed to a parent, do nothing
47+
if (this._subscribed.indexOf(chip) > -1) {
48+
return;
49+
}
50+
51+
// Watch for focus events outside of the keyboard navigation
52+
chip.onFocus.subscribe(() => {
53+
let chipIndex: number = this._chips.toArray().indexOf(chip);
54+
55+
if (this.isValidIndex(chipIndex)) {
56+
this.setFocus(chipIndex, false);
57+
}
58+
});
59+
60+
// On destroy, remove the item from our list, and check focus
61+
chip.destroy.subscribe(() => {
62+
let chipIndex: number = this._chips.toArray().indexOf(chip);
63+
64+
if (this.isValidIndex(chipIndex)) {
65+
// Check whether the chip is the last item
66+
if (chipIndex < this._chips.length - 1) {
67+
this.setFocus(chipIndex);
68+
} else if (chipIndex - 1 >= 0) {
69+
this.setFocus(chipIndex - 1);
70+
}
71+
}
72+
73+
this._subscribed.splice(this._subscribed.indexOf(chip), 1);
74+
chip.destroy.unsubscribe();
75+
});
76+
77+
this._subscribed.push(chip);
78+
}
79+
80+
/**
81+
* Utility to ensure all indexes are valid.
82+
*
83+
* @param index The index to be checked.
84+
* @returns {boolean} True if the index is valid for our list of chips.
85+
*/
86+
private isValidIndex(index: number): boolean {
87+
return index >= 0 && index < this._chips.length;
88+
}
89+
}

0 commit comments

Comments
 (0)