From 55f770d0a9e9e8bdc339f2dc6816056a04e00987 Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Mon, 7 Nov 2016 02:06:38 -0600 Subject: [PATCH] Add chip keyboard support. - Up/down arrows navigate chips. - Clicking a chip properly focuses it for subsequent keyboard navigation. - More demos. References #120. --- src/demo-app/chips/chips-demo.html | 68 +++++++ src/demo-app/chips/chips-demo.scss | 6 + src/demo-app/chips/chips-demo.ts | 41 ++++ src/demo-app/demo-app-module.ts | 2 + src/demo-app/demo-app/demo-app.ts | 1 + src/demo-app/demo-app/routes.ts | 2 + src/lib/chips/README.md | 201 ++++++++++++++++++++ src/lib/chips/_chips-theme.scss | 29 +++ src/lib/chips/chip-list-key-manager.spec.ts | 79 ++++++++ src/lib/chips/chip-list-key-manager.ts | 72 +++++++ src/lib/chips/chip-list.spec.ts | 53 ++++++ src/lib/chips/chip-list.ts | 77 ++++++++ src/lib/chips/chip.spec.ts | 78 ++++++++ src/lib/chips/chip.ts | 107 +++++++++++ src/lib/chips/chips.scss | 82 ++++++++ src/lib/chips/index.ts | 2 + src/lib/core/a11y/list-key-manager.spec.ts | 22 ++- src/lib/core/a11y/list-key-manager.ts | 18 +- src/lib/core/keyboard/keycodes.ts | 3 + src/lib/core/theming/_all-theme.scss | 2 + src/lib/index.ts | 1 + src/lib/module.ts | 3 + 22 files changed, 941 insertions(+), 8 deletions(-) create mode 100644 src/demo-app/chips/chips-demo.html create mode 100644 src/demo-app/chips/chips-demo.scss create mode 100644 src/demo-app/chips/chips-demo.ts create mode 100644 src/lib/chips/README.md create mode 100644 src/lib/chips/_chips-theme.scss create mode 100644 src/lib/chips/chip-list-key-manager.spec.ts create mode 100644 src/lib/chips/chip-list-key-manager.ts create mode 100644 src/lib/chips/chip-list.spec.ts create mode 100644 src/lib/chips/chip-list.ts create mode 100644 src/lib/chips/chip.spec.ts create mode 100644 src/lib/chips/chip.ts create mode 100644 src/lib/chips/chips.scss create mode 100644 src/lib/chips/index.ts diff --git a/src/demo-app/chips/chips-demo.html b/src/demo-app/chips/chips-demo.html new file mode 100644 index 000000000000..ad4c8581776c --- /dev/null +++ b/src/demo-app/chips/chips-demo.html @@ -0,0 +1,68 @@ +
+
+

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

+ + + + {{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 new file mode 100644 index 000000000000..c0c2af7666c8 --- /dev/null +++ b/src/demo-app/chips/chips-demo.scss @@ -0,0 +1,6 @@ +.chips-demo { + .md-chip-list-stacked { + display: block; + max-width: 200px; + } +} \ No newline at end of file diff --git a/src/demo-app/chips/chips-demo.ts b/src/demo-app/chips/chips-demo.ts new file mode 100644 index 000000000000..b81acc4654b5 --- /dev/null +++ b/src/demo-app/chips/chips-demo.ts @@ -0,0 +1,41 @@ +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' } + ]; + favorites: Person[] = []; + + 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/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 5f4a02dc5f68..ec35c947b742 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -13,6 +13,7 @@ import {IconDemo} from './icon/icon-demo'; import {GesturesDemo} from './gestures/gestures-demo'; import {InputDemo} from './input/input-demo'; import {CardDemo} from './card/card-demo'; +import {ChipsDemo} from './chips/chips-demo'; import {RadioDemo} from './radio/radio-demo'; import {ButtonToggleDemo} from './button-toggle/button-toggle-demo'; import {ProgressCircleDemo} from './progress-circle/progress-circle-demo'; @@ -49,6 +50,7 @@ import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-d ButtonDemo, ButtonToggleDemo, CardDemo, + ChipsDemo, CheckboxDemo, DemoApp, DialogDemo, diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index ae2ab491bdf6..2b229a414054 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -23,6 +23,7 @@ export class DemoApp { {name: 'Button', route: 'button'}, {name: 'Button Toggle', route: 'button-toggle'}, {name: 'Card', route: 'card'}, + {name: 'Chips', route: 'chips'}, {name: 'Checkbox', route: 'checkbox'}, {name: 'Dialog', route: 'dialog'}, {name: 'Gestures', route: 'gestures'}, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index f2e1cf7b68e9..285be668b898 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -22,6 +22,7 @@ import {SlideToggleDemo} from '../slide-toggle/slide-toggle-demo'; import {SliderDemo} from '../slider/slider-demo'; import {RadioDemo} from '../radio/radio-demo'; import {CardDemo} from '../card/card-demo'; +import {ChipsDemo} from '../chips/chips-demo'; import {MenuDemo} from '../menu/menu-demo'; import {RippleDemo} from '../ripple/ripple-demo'; import {DialogDemo} from '../dialog/dialog-demo'; @@ -34,6 +35,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: '', component: Home}, {path: 'button', component: ButtonDemo}, {path: 'card', component: CardDemo}, + {path: 'chips', component: ChipsDemo}, {path: 'radio', component: RadioDemo}, {path: 'select', component: SelectDemo}, {path: 'sidenav', component: SidenavDemo}, diff --git a/src/lib/chips/README.md b/src/lib/chips/README.md new file mode 100644 index 000000000000..ff3f3f8a0f51 --- /dev/null +++ b/src/lib/chips/README.md @@ -0,0 +1,201 @@ +# md-chip-list + +`md-chip-list` provides a horizontal display of (optionally) selectable, addable, and removable, +items and an input to create additional ones (again; optional). You can read more about chips +in the [Material Design spec](https://material.google.com/components/chips.html). + +## Requirements + +1. Show a static list of chips with proper styling +2. Show a dynamic list of chips with an Input +3. Show a dynamic list of chips with an Input/Autocomplete, or a Select + +#### Additional Requirements + +1. A Chips' Autocomplete or Select should not show items for an existing chip + +## Usage + +### Static Chips + +Static chips can be used to inform a user about a list of existing, unmodifiable, items. + +##### static-chips-1.html +```html + + Baseball + Basketball + Football + +``` + +Alternatively, you can apply the chip styling to an existing element and this will handle focus and +selection events. + +##### static-chips-2.html + +```html +
+ +
+``` + +### Dynamic Chips + +If you want any of the dynamic functionality supplied by chips, you may utilize the `md-chip-input` +component (and associated `(chipAdded)` event) in tandem with the `(remove)` event of the `md-chip` +component. + +##### dynamic-chips-1.html + +The most basic version simply utilizes the `(chipAdded)` event to push the requested chip onto +the list. This event handles the keyboard interaction and would also work in tandem with the +`[chip-separators]` option that allows user-defined separator keys (like `,` or `;`). + +```html + + + {{tag.name}} + + + + +``` + + +##### autocomplete-chips.html + +If you would like to help users by providing some filtering of predefined, options, +you can simply apply the `md-autocomplete`. + +```html + + + {{tag.name}} + + + + + + + + +``` + +_**Note:** Notice how this `(remove)` utilizes the `tag` of the `*ngFor` rather than the +`$chip` variable. For chips which are more complex than strings, this may be desirable._ + +##### select-chips.html + +Finally, you could use an `md-select` instead of an `md-autocomplete` if you do not wish +the user to be able to create new/unknown chips. + +```html + + + {{favorite.name}} + + + + + + {{ food.viewValue }} + + +``` + +## Templates + +Obviously, these kinds of complex controls may be a bit burdensome for many developers, +so I propose we add some "high-level" template components which can be used to make +development easier and more in-line with what most users are expecting, while still +providing full flexibility for users who want a more custom component. + +##### md-static-chips + +The most basic example would be static chips bound to a list of elements which provides +the `chip-text` expression allowing the component to render the chip without requiring +a template. + +```html + + +``` + +##### md-dynamic-chips + +There are two common variations of the dynamic chips: + +1. Chips with just an input +2. Chips with an input associated with an autocomplete + +Both are listed below. + +*only the input* +```html + + +``` + +*with an autocomplete* +```html + + +``` + +There are two methods for providing the suggestions: + +1. `[suggestions]="mySuggestions"` - In this mode, the suggestions are bound to an array + or list and the filtering is handled by the `md-dynamic-chips` component. +2. `[suggestions]="getSuggestions($query)"` - In this mode, the suggestions are filtered + by user-provided code and can immediately return with the value, or it can return a + promise for evaluation at a later time. + +The various behaviors could be also be controlled using the `capabilities` option. + +```html + + +``` + +In this way, you can easily customize which behaviors are allowed at any given time. + +_**Note:** We should also support the `ng-disabled`/`disabled` parameter which turns off all +capabilities._ + +##### md-contact-chips + +Finally, we would offer a simple component for the common case of the Contact Chips which +appears multiple times in the spec. + +```html + + +``` + +The `getInfo($chip)` method should return an object with `name`, `email` and `image` properties +to be utilized by the contact chips during rendering. If not provided, the default would simply be + +```js +{ + name: $chip.name, + email: $chip.email, + image: $chip.image +} +```` + +Similar to the `md-dynamic-chips` and the suggestions, the developer could also supply +`[contacts]="getContacts($query)"` to asynchronously search for the contacts, or to have +more control over the filtered list. diff --git a/src/lib/chips/_chips-theme.scss b/src/lib/chips/_chips-theme.scss new file mode 100644 index 000000000000..da47e43120af --- /dev/null +++ b/src/lib/chips/_chips-theme.scss @@ -0,0 +1,29 @@ +@import '../core/theming/theming'; + +@mixin md-chips-theme($theme) { + $is-dark-theme: map-get($theme, is-dark); + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, warn); + $background: map-get($theme, background); + + + .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); + } + &.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-key-manager.spec.ts b/src/lib/chips/chip-list-key-manager.spec.ts new file mode 100644 index 000000000000..4cf0d88372e6 --- /dev/null +++ b/src/lib/chips/chip-list-key-manager.spec.ts @@ -0,0 +1,79 @@ +import {QueryList} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; +import {MdBasicChip} from './index'; +import {ChipListKeyManager} from './chip-list-key-manager'; + +class FakeChip extends MdBasicChip { + constructor() { + // Pass in null for the renderer/elementRef + super(null, null); + } + + // Override the 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..9890106862cb --- /dev/null +++ b/src/lib/chips/chip-list-key-manager.ts @@ -0,0 +1,72 @@ +import {QueryList} from '@angular/core'; +import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {MdBasicChip} from './chip'; + +export class ChipListKeyManager extends ListKeyManager { + private _subscribed: MdBasicChip[] = []; + + constructor(private _chips: QueryList) { + super(_chips); + + this.subscribeChips(this._chips); + + this._chips.changes.subscribe((chips: QueryList) => { + this.subscribeChips(chips); + }); + } + + onKeydown(event: KeyboardEvent): void { + let focusedChip: MdBasicChip; + + if (this.isValidIndex(this.focusedItemIndex)) { + focusedChip = this._chips.toArray()[this.focusedItemIndex]; + } + + super.onKeydown(event); + } + + protected subscribeChips(chips: QueryList): void { + chips.forEach((chip: MdBasicChip) => { + this.addChip(chip); + }); + } + + 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)) { + // + 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); + } + + private isValidIndex(index: number): boolean { + return index >= 0 && index < this._chips.length; + } +} diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts new file mode 100644 index 000000000000..14c1b69fa674 --- /dev/null +++ b/src/lib/chips/chip-list.spec.ts @@ -0,0 +1,53 @@ +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'; + +describe('MdChip', () => { + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdChipsModule.forRoot()], + declarations: [ + StaticChipList + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behaviors', () => { + let chipListDebugElement: DebugElement; + let chipListNativeElement: HTMLElement; + let chipListInstance: MdChipList; + let testComponent: StaticChipList; + + beforeEach(() => { + fixture = TestBed.createComponent(StaticChipList); + fixture.detectChanges(); + + chipListDebugElement = fixture.debugElement.query(By.directive(MdChipList)); + chipListNativeElement = chipListDebugElement.nativeElement; + chipListInstance = chipListDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + }); + + it('adds the `md-chip-list` class', () => { + expect(chipListNativeElement.classList).toContain('md-chip-list'); + }); + }); +}); + +@Component({ + template: ` + + {{name}} 1 + {{name}} 2 + {{name}} 3 + + ` +}) +class StaticChipList { + name: 'Test'; +} diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts new file mode 100644 index 000000000000..96278afc113a --- /dev/null +++ b/src/lib/chips/chip-list.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + ModuleWithProviders, + NgModule, + QueryList, + ViewEncapsulation +} from '@angular/core'; + +import {MdBasicChip, MdChip} from './chip'; +import {ChipListKeyManager} from './chip-list-key-manager'; + +export const MD_CHIP_LIST_COMPONENT_CONFIG: Component = { + moduleId: module.id, + selector: 'md-chip-list, [md-chip-list]', + template: `
`, + host: { + // Properties + 'tabindex': '0', + 'role': 'listbox', + + // Events + '(focus)': 'focus($event)', + '(keydown)': 'keydown($event)' + }, + queries: { + chips: new ContentChildren(MdBasicChip) + }, + styleUrls: ['chips.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}; + +@Component(MD_CHIP_LIST_COMPONENT_CONFIG) +export class MdChipList { + + private _keyManager: ChipListKeyManager; + + public chips: QueryList; + + constructor(private _elementRef: ElementRef) { + } + + ngAfterContentInit(): void { + this._elementRef.nativeElement.classList.add('md-chip-list'); + + 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, MdBasicChip, MdChip], + declarations: [MdChipList, MdBasicChip, MdChip] +}) +export class MdChipsModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: MdChipsModule, + providers: [] + }; + } +} diff --git a/src/lib/chips/chip.spec.ts b/src/lib/chips/chip.spec.ts new file mode 100644 index 000000000000..710922a3eb87 --- /dev/null +++ b/src/lib/chips/chip.spec.ts @@ -0,0 +1,78 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, DebugElement} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MdChip, MdChipEvent, MdChipsModule} from './index'; + +describe('MdChip', () => { + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdChipsModule.forRoot()], + declarations: [ + SingleChip + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behaviors', () => { + let chipDebugElement: DebugElement; + let chipNativeElement: HTMLElement; + let chipInstance: MdChip; + let testComponent: SingleChip; + + beforeEach(() => { + fixture = TestBed.createComponent(SingleChip); + fixture.detectChanges(); + + chipDebugElement = fixture.debugElement.query(By.directive(MdChip)); + chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + }); + + 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}} + ` +}) +class SingleChip { + name: string = 'Test'; + + chipFocus(event: MdChipEvent) {} + chipDestroy(event: MdChipEvent) {} +} diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts new file mode 100644 index 000000000000..c687adca53ad --- /dev/null +++ b/src/lib/chips/chip.ts @@ -0,0 +1,107 @@ +import { + // Classes + Component, + ElementRef, + EventEmitter, + OnDestroy, + Renderer, + + // Functions + forwardRef +} from '@angular/core'; + +import {MdFocusable} from '../core/a11y/list-key-manager'; + +export class MdChipEvent { + chip: MdBasicChip; +} + +export const MD_BASIC_CHIP_COMPONENT_CONFIG: Component = { + selector: 'md-basic-chip, [md-basic-chip]', + template: ``, + host: { + // Properties + 'tabindex': '-1', + 'role': 'option', + + // Attributes + '[attr.disabled]': 'disabled', + '[attr.aria-disabled]': 'isAriaDisabled', + + // Events + '(click)': 'click($event)', + }, + inputs: ['disabled'], + outputs: ['didfocus', 'destroy'] +}; + +@Component(MD_BASIC_CHIP_COMPONENT_CONFIG) +export class MdBasicChip implements MdFocusable, OnDestroy { + + protected _disabled: boolean = false; + + // Declare outputs + public didfocus = new EventEmitter(); + public destroy = new EventEmitter(); + + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) {} + + ngAfterContentInit(): void {} + + ngOnDestroy(): void { + this.destroy.emit({chip: this}); + } + + focus(): void { + this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus'); + this.didfocus.emit({chip: this}); + } + + get disabled(): boolean { + return this._disabled; + } + + set disabled(value: boolean) { + this._disabled = (value === false || value === undefined) ? null : true; + } + + get isAriaDisabled(): string { + return String(this.disabled); + } + + 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(); + } + } +} + +export const MD_CHIP_COMPONENT_CONFIG: Component = { + 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, + + selector: 'md-chip, [md-chip]', + providers: [{provide: MdBasicChip, useExisting: forwardRef(() => MdChip)}] +}; + +@Component(MD_CHIP_COMPONENT_CONFIG) +export class MdChip extends MdBasicChip { + + constructor(protected _renderer: Renderer, protected _elementRef: ElementRef) { + super(_renderer, _elementRef); + } + + ngAfterContentInit(): void { + super.ngAfterContentInit(); + + this._elementRef.nativeElement.classList.add('md-chip'); + } + +} diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss new file mode 100644 index 000000000000..d2f50d84754a --- /dev/null +++ b/src/lib/chips/chips.scss @@ -0,0 +1,82 @@ +$md-chip-vertical-padding: 8px; +$md-chip-horizontal-padding: 12px; +$md-chip-font-size: 13px; +$md-chip-line-height: 16px; + +$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 { + display: inline-block; + padding: $md-chip-vertical-padding $md-chip-horizontal-padding + $md-chip-vertical-padding $md-chip-horizontal-padding; + border-radius: $md-chip-horizontal-padding * 2; + + background-color: #e0e0e0; + color: rgba(0, 0, 0, 0.87); + 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/chips/index.ts b/src/lib/chips/index.ts new file mode 100644 index 000000000000..938151ce2bea --- /dev/null +++ b/src/lib/chips/index.ts @@ -0,0 +1,2 @@ +export * from './chip-list'; +export * from './chip'; diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts index 918f9c049bf5..2eab6118c916 100644 --- a/src/lib/core/a11y/list-key-manager.spec.ts +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -15,11 +15,13 @@ class FakeQueryList extends QueryList { } } -const DOWN_ARROW_EVENT = { keyCode: DOWN_ARROW } as KeyboardEvent; -const UP_ARROW_EVENT = { keyCode: UP_ARROW } as KeyboardEvent; -const TAB_EVENT = { keyCode: TAB } as KeyboardEvent; -const HOME_EVENT = { keyCode: HOME } as KeyboardEvent; -const END_EVENT = { keyCode: END } as KeyboardEvent; +function noop() {} + +const DOWN_ARROW_EVENT = { keyCode: DOWN_ARROW, preventDefault: noop } as KeyboardEvent; +const UP_ARROW_EVENT = { keyCode: UP_ARROW, preventDefault: noop } as KeyboardEvent; +const TAB_EVENT = { keyCode: TAB, preventDefault: noop } as KeyboardEvent; +const HOME_EVENT = { keyCode: HOME, preventDefault: noop } as KeyboardEvent; +const END_EVENT = { keyCode: END, preventDefault: noop } as KeyboardEvent; describe('ListKeyManager', () => { let keyManager: ListKeyManager; @@ -180,6 +182,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 8e447ff0c327..59a5da5ebe52 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: this._tabOut.next(null); 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; diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index 084e2585350e..4c7a252ea4c4 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -4,6 +4,7 @@ @import '../../button-toggle/button-toggle-theme'; @import '../../card/card-theme'; @import '../../checkbox/checkbox-theme'; +@import '../../chips/chips-theme'; @import '../../dialog/dialog-theme'; @import '../../grid-list/grid-list-theme'; @import '../../icon/icon-theme'; @@ -29,6 +30,7 @@ @include md-button-toggle-theme($theme); @include md-card-theme($theme); @include md-checkbox-theme($theme); + @include md-chips-theme($theme); @include md-dialog-theme($theme); @include md-grid-list-theme($theme); @include md-icon-theme($theme); diff --git a/src/lib/index.ts b/src/lib/index.ts index e242a9fc8777..7202a6b57f55 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -4,6 +4,7 @@ export * from './module'; export * from './button/index'; export * from './button-toggle/index'; export * from './card/index'; +export * from './chips/index'; export * from './checkbox/index'; export * from './dialog/index'; export * from './grid-list/index'; diff --git a/src/lib/module.ts b/src/lib/module.ts index 31d18001e089..1a5d835c1bac 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -21,6 +21,7 @@ import {MdSidenavModule} from './sidenav/index'; import {MdListModule} from './list/index'; import {MdGridListModule} from './grid-list/index'; import {MdCardModule} from './card/index'; +import {MdChipsModule} from './chips/index'; import {MdIconModule} from './icon/index'; import {MdProgressCircleModule} from './progress-circle/index'; import {MdProgressBarModule} from './progress-bar/index'; @@ -37,6 +38,7 @@ const MATERIAL_MODULES = [ MdButtonModule, MdButtonToggleModule, MdCardModule, + MdChipsModule, MdCheckboxModule, MdDialogModule, MdGridListModule, @@ -68,6 +70,7 @@ const MATERIAL_MODULES = [ imports: [ MdButtonModule.forRoot(), MdCardModule.forRoot(), + MdChipsModule.forRoot(), MdCheckboxModule.forRoot(), MdGridListModule.forRoot(), MdInputModule.forRoot(),