From 22d4342c4072560e3903820c4009f8129ec0d184 Mon Sep 17 00:00:00 2001 From: Ed Morales Date: Mon, 29 May 2017 18:02:42 -0700 Subject: [PATCH] feat(chips): chip templates + object list support + async loading support. (closes #252) (closes #359) (closes #601) (#626) * feat(chips): ability to use object lists and set templates for both autocomplete and chips this will be the base to support contact chips. First we need to make sure it works fine with objects and strings, and add good a11y around this. * chore(chips): update demo with a better example * feat(chips): make children be centered and vertical aligned (including children in templates) * chore(chips): remove unused CSS rule for datalist * fx(chips): remove [Object] value when adding an object from the autocomplete in a11y mode * fix(chips): delete by index rather than by value * *BREAKING CHANGE* feat(chips): abstract the autocomplete filtering and add debounce input it makes more sense to have the filtering done outside of chips and provide examples on how to achieve it since this way chips are agnostic of local vs server side filtering + string vs object filtering * chore(chips): update README.md and codeblocks in inputs/outputs * feat(chips): add [td-autocomplete-header] to be able to add a loader or something on long filters * chore(chips): remove td-autocomplete-header and have it inject anything under chips * chore(chips): update demos and load README.md in docs * fix(): chips README.md not loading highlight correctly * fix(chips): prevent chip duplication when pressing enter super fast * fix(chips): check for undefined value from input * perf(chips): support OnPush change detection * fix(chips): make 4th demo work again * fix(chips): validate against use case where selection happened without requireMatch * chore(chips): fix unit tests and add unit tests for new behavior * fix(chips): underline stops working in beta.6 * perf(chips): remove usage of async in autocomplete and use simple array beta.6 has some nuances around autocomplete so its needed to have more control * fix(): unit tests * chore(chips): make underline animatate the same way as material * fix(chips): keep focused state as long as you keep clicking inside the chips context * fix(): ninja fix mat-color function * feat(chips): make focused state remain while inside the chip context * chore(chips): polish code and add code blocks * fix(chips): block click event when clicking on host or td-chips-wrapper * fix(chips): a11y left + right arrows * chore(chips): modify the demos for a better chips experience * fix(chips): check if value is part of the ngModel * chore(chips): added more unit tests --- .../components/chips/chips.component.html | 273 ++++++++--- .../components/chips/chips.component.ts | 71 ++- .../components/components.module.ts | 4 +- src/platform/core/chips/README.md | 37 +- src/platform/core/chips/_chips-theme.scss | 2 +- src/platform/core/chips/chips.component.html | 83 ++-- src/platform/core/chips/chips.component.scss | 88 ++-- .../core/chips/chips.component.spec.ts | 421 +++++++++++++--- src/platform/core/chips/chips.component.ts | 460 +++++++++++++----- src/platform/core/chips/chips.module.ts | 8 +- 10 files changed, 1090 insertions(+), 357 deletions(-) diff --git a/src/app/components/components/chips/chips.component.html b/src/app/components/components/chips/chips.component.html index 3265649bcf..457f657a9f 100644 --- a/src/app/components/components/chips/chips.component.html +++ b/src/app/components/components/chips/chips.component.html @@ -1,17 +1,18 @@ - Chips & Autocomplete + Chips & Autocomplete with strings Autocomplete with chips and no custom inputs Demo
-
Type and select a preset option:
+
Type and select a preset option or press enter:
@@ -23,10 +24,11 @@ ]]> @@ -38,7 +40,7 @@ readOnly: boolean = false; chipAddition: boolean = true; - items: string[] = [ + strings: string[] = [ 'stepper', 'expansion-panel', 'markdown', @@ -52,8 +54,25 @@ 'need more?', ]; - itemsRequireMatch: string[] = this.items.slice(0, 6); + filteredStrings: string[]; + stringsModel: string[] = this.strings.slice(0, 6); + + ngOnInit(): void { + this.filterStrings(''); + } + + filterStrings(value: string): void { + this.filteredStrings = this.strings.filter((item: any) => { + if (value) { + return item.toLowerCase().indexOf(value.toLowerCase()) > -1; + } else { + return false; + } + }).filter((filteredItem: any) => { + return this.stringsModel ? this.stringsModel.indexOf(filteredItem) < 0 : true; + }); + } } ]]> @@ -75,6 +94,167 @@
+ + Chips & Autocomplete with objects + Autocomplete with chips and templates + + + + Demo +
+
Type and select a preset option or press enter:
+ + + {{chip.city}}, (Pop: {{chip.population}}) + + + location_city {{option.city}} + + +
+
+ + Code + +

HTML:

+ + + + { {chip.city} }, (Pop: { {chip.population} }) + + + location_city { {option.city} } + + + ]]> + +

Typescript:

+ + { + if (value) { + return obj.city.toLowerCase().indexOf(value.toLowerCase()) > -1; + } else { + return false; + } + }).filter((filteredObj: any) => { + return this.objectsModel ? this.objectsModel.indexOf(filteredObj) < 0 : true; + }); + } + } + ]]> + +
+
+
+
+ + Chips & async Autocomplete + Load autocomplete items asynchronous when typing in the input + + + + Demo +
+
Type and see how it will load items async:
+ + + +
+
+ + Code + +

HTML:

+ + + + + ]]> + +

Typescript:

+ + { + this.filteredAsync = this.strings.filter((item: any) => { + return item.toLowerCase().indexOf(value.toLowerCase()) > -1; + }).filter((filteredItem: any) => { + return this.asyncModel ? this.asyncModel.indexOf(filteredItem) < 0 : true; + }); + this.filteringAsync = false; + }, 2000); + } + } + } + ]]> + +
+
+
+
Autocomplete with custom inputs Autocomplete demo allowing custom inputs @@ -84,7 +264,7 @@ Demo
Type and select option or enter custom text and press enter:
- +
@@ -93,7 +273,7 @@

HTML:

+ ]]>

Typescript:

@@ -101,7 +281,7 @@
- - TdChipsComponent - How to use this component - - -

]]>

-

Use ]]> element to generate a list of strings as chips.

-

Add the [items] attribute to enable the autocomplete with a search list, and [requireMatch] to validate the input against the provided search list.

-

When used with forms, you can track change-states [dirty/pristine] and [touched/untouched].

-

Since [(ngModel)] would be an array, you need to implement a custom validator for [valid/invalid] when its empty.

-

Properties:

-

The ]]> component has {{chipsAttrs.length}} properties:

- - - -

{{attr.name}}: {{attr.type}}

-

{{attr.description}}

-
- -
-
-

Example:

-

HTML:

- - - - ]]> - -

Typescript:

- - - -

Setup:

-

Import the [CovalentChipsModule] in your NgModule:

- - - -
-
+ + diff --git a/src/app/components/components/chips/chips.component.ts b/src/app/components/components/chips/chips.component.ts index c6952a6dcb..808d0488a4 100644 --- a/src/app/components/components/chips/chips.component.ts +++ b/src/app/components/components/chips/chips.component.ts @@ -1,4 +1,4 @@ -import { Component, HostBinding } from '@angular/core'; +import { Component, HostBinding, OnInit } from '@angular/core'; import { slideInDownAnimation } from '../../../app.animations'; @@ -8,7 +8,7 @@ import { slideInDownAnimation } from '../../../app.animations'; templateUrl: './chips.component.html', animations: [slideInDownAnimation], }) -export class ChipsDemoComponent { +export class ChipsDemoComponent implements OnInit { @HostBinding('@routeAnimation') routeAnimation: boolean = true; @HostBinding('class.td-route-animation') classAnimation: boolean = true; @@ -49,7 +49,9 @@ export class ChipsDemoComponent { readOnly: boolean = false; chipAddition: boolean = true; - items: string[] = [ + filteringAsync: boolean = false; + + strings: string[] = [ 'stepper', 'expansion-panel', 'markdown', @@ -63,6 +65,67 @@ export class ChipsDemoComponent { 'need more?', ]; - itemsRequireMatch: string[] = this.items.slice(0, 6); + filteredStrings: string[]; + + stringsModel: string[] = this.strings.slice(0, 6); + + objects: any[] = [ + {id: 1, city: 'San Diego', population: '4M'}, + {id: 2, city: 'San Franscisco', population: '6M'}, + {id: 3, city: 'Los Angeles', population: '5M'}, + {id: 4, city: 'Austin', population: '3M'}, + {id: 5, city: 'New York City', population: '10M'}, + ]; + + filteredObjects: string[]; + + objectsModel: string[] = this.objects.slice(0, 2); + + filteredAsync: string[]; + + asyncModel: string[] = this.strings.slice(0, 2); + + ngOnInit(): void { + this.filterStrings(''); + this.filterObjects(''); + } + + filterStrings(value: string): void { + this.filteredStrings = this.strings.filter((item: any) => { + if (value) { + return item.toLowerCase().indexOf(value.toLowerCase()) > -1; + } else { + return false; + } + }).filter((filteredItem: any) => { + return this.stringsModel ? this.stringsModel.indexOf(filteredItem) < 0 : true; + }); + } + + filterObjects(value: string): void { + this.filteredObjects = this.objects.filter((obj: any) => { + if (value) { + return obj.city.toLowerCase().indexOf(value.toLowerCase()) > -1; + } else { + return false; + } + }).filter((filteredObj: any) => { + return this.objectsModel ? this.objectsModel.indexOf(filteredObj) < 0 : true; + }); + } + filterAsync(value: string): void { + this.filteredAsync = undefined; + if (value) { + this.filteringAsync = true; + setTimeout(() => { + this.filteredAsync = this.strings.filter((item: any) => { + return item.toLowerCase().indexOf(value.toLowerCase()) > -1; + }).filter((filteredItem: any) => { + return this.asyncModel ? this.asyncModel.indexOf(filteredItem) < 0 : true; + }); + this.filteringAsync = false; + }, 2000); + } + } } diff --git a/src/app/components/components/components.module.ts b/src/app/components/components/components.module.ts index b4fd411a94..61d770f626 100644 --- a/src/app/components/components/components.module.ts +++ b/src/app/components/components/components.module.ts @@ -34,7 +34,8 @@ import { NgxChartsModule } from '@swimlane/ngx-charts'; import { TranslateModule } from '@ngx-translate/core'; import { MdButtonModule, MdListModule, MdIconModule, MdCardModule, MdMenuModule, MdInputModule, MdButtonToggleModule, MdSlideToggleModule, - MdSelectModule, MdToolbarModule, MdTabsModule, MdTooltipModule, MdCoreModule, MdAutocompleteModule } from '@angular/material'; + MdSelectModule, MdToolbarModule, MdTabsModule, MdTooltipModule, MdCoreModule, MdAutocompleteModule, + MdProgressBarModule } from '@angular/material'; import { CovalentCommonModule, CovalentLayoutModule, CovalentMediaModule, CovalentExpansionPanelModule, CovalentFileModule, CovalentStepsModule, CovalentLoadingModule, CovalentDialogsModule, CovalentSearchModule, CovalentPagingModule, @@ -92,6 +93,7 @@ import { DocumentationToolsModule } from '../../documentation-tools'; MdToolbarModule, MdTabsModule, MdTooltipModule, + MdProgressBarModule, /** Covalent Modules */ CovalentCommonModule, CovalentLayoutModule, diff --git a/src/platform/core/chips/README.md b/src/platform/core/chips/README.md index 4e7410a4aa..e75c47cd6f 100644 --- a/src/platform/core/chips/README.md +++ b/src/platform/core/chips/README.md @@ -1,8 +1,8 @@ # td-chips -`td-chips` element generates a list of strings as chips. +`td-chips` element generates a list of strings or objects as chips. -Add the [items] attribute to enable the autocomplete with a search list, and [requireMatch] to validated the input against the provided search list. +Add the [items] attribute to enable the autocomplete with a list, and [requireMatch] to not allow custom values. ## API Summary @@ -10,12 +10,14 @@ Properties: | Name | Type | Description | | --- | --- | --- | -| `items?` | `string[]` | Enables Autocompletion with the provided list of search strings. -| `readOnly` | `boolean` | Disables the chip input and removal. -| `requireMatch?` | `boolean` | Validates input against the provided search list before adding it to the model. If it doesnt exist, it cancels the event. +| `items?` | `any[]` | Renders the `md-autocomplete` with the provided list to display as options. +| `requireMatch?` | `boolean` | Blocks custom inputs and only allows selections from the autocomplete list. | `placeholder?` | `string` | Placeholder for the autocomplete input. -| `add?` | `function` | Method to be executed when string is added as chip through the autocomplete. Sends chip value as event. -| `remove?` | `function` | Method to be executed when string is removed as chip with the "remove" button. Sends chip value as event. +| `chipAddition` | `boolean` | Disables the ability to add chips. When setting readOnly as true, this will be overriden. Defaults to true. +| `debounce` | `string` | Debounce timeout between keypresses. Defaults to 200. +| `add?` | `function` | Method to be executed when a chip is added. Sends chip value as event. +| `remove?` | `function` | Method to be executed when a chip is removed. Sends chip value as event. +| `inputChange?` | `function` | Method to be executed when the value in the autocomplete input changes. Sends string value as event. ## Setup @@ -37,7 +39,22 @@ export class MyModule {} Example for HTML usage: - ```html - +```html + + + {{chip}} + + + {{option}} + + // anything below it - ``` \ No newline at end of file +``` diff --git a/src/platform/core/chips/_chips-theme.scss b/src/platform/core/chips/_chips-theme.scss index 2ad63ddd1a..bc67beaf32 100644 --- a/src/platform/core/chips/_chips-theme.scss +++ b/src/platform/core/chips/_chips-theme.scss @@ -22,7 +22,7 @@ } } } - md-icon { + md-icon.td-chip-removal { color: mat-color($foreground, hint-text); &:hover { color: mat-color($foreground, icon); diff --git a/src/platform/core/chips/chips.component.html b/src/platform/core/chips/chips.component.html index b8df30f41e..454a036081 100644 --- a/src/platform/core/chips/chips.component.html +++ b/src/platform/core/chips/chips.component.html @@ -1,38 +1,53 @@ -
- - - - {{chip}} - +
+ + +
+
+ {{chip}} + + +
+ cancel - +
+
+
+ + + + + + + {{item}} + + + - - - - - - {{item}} - - - -
- -
+
+
+ +
+ \ No newline at end of file diff --git a/src/platform/core/chips/chips.component.scss b/src/platform/core/chips/chips.component.scss index d746e6458d..2f9d607a30 100644 --- a/src/platform/core/chips/chips.component.scss +++ b/src/platform/core/chips/chips.component.scss @@ -3,73 +3,79 @@ :host { display: block; padding: 0px 5px 0px 5px; + .td-chips-wrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } /deep/ { .mat-input-wrapper { margin-bottom: 2px; } + /* TODO see if we can make styles more abstract to future proof for contact chips */ .mat-basic-chip { display: inline-block; cursor: default; border-radius: 16px; - line-height: 32px; @include rtl(margin, 8px 8px 0 0, 8px 0 0 8px); - padding: 0 12px; + .td-basic-chip { + min-height: 30px; + font-size: 14px; + @include rtl(padding, 0 0 0 12px, 0 12px 0 0); + } box-sizing: border-box; max-width: 100%; position: relative; - md-icon { - position: relative; - top: 5px; - @include rtl(left, 5px, auto); - @include rtl(right, auto, 5px); - height: 18px; - width: 18px; - font-size: 19px; + &.td-chip-disabled { + @include rtl(padding, 0 12px 0 0, 0 0 0 12px); + } + md-icon.td-chip-removal { + margin: 0 4px; + font-size: 21px; &:hover { cursor: pointer; } } } } -} - -.mat-input-underline { - position: relative; - height: 1px; - width: 100%; - - &.mat-disabled { - border-top: 0; - background-position: 0; - background-size: 4px 1px; - background-repeat: repeat-x; - } - - .mat-input-ripple { - position: absolute; - height: 2px; - z-index: 1; - top: -1px; + .mat-input-underline { + position: relative; + height: 1px; width: 100%; - transform-origin: top; - opacity: 0; - transform: scaleY(0); - &.mat-warn { - opacity: 1; - transform: scaleY(1); + margin-top: 4px; + border-top-width: 1px; + border-top-style: solid; + + &.mat-disabled { + border-top: 0; + background-position: 0; + background-size: 4px 1px; + background-repeat: repeat-x; } - &.mat-focused { - opacity: 1; - transform: scaleY(1); + + .mat-input-ripple { + position: absolute; + height: 2px; + z-index: 1; + top: -1px; + width: 100%; + transform-origin: 50%; + transform: scaleX(0.5); + visibility: hidden; + transition: background-color .3s cubic-bezier(.55,0,.55,.2); + &.mat-focused { + visibility: visible; + transform: scaleX(1); + transition: transform 150ms linear, + background-color .3s cubic-bezier(.55,0,.55,.2); + } } } } :host { /deep/ md-input-container { - input::-webkit-calendar-picker-indicator { // removes input arrow for datalist in chrome - display: none; - } .mat-input-underline { display: none; } diff --git a/src/platform/core/chips/chips.component.spec.ts b/src/platform/core/chips/chips.component.spec.ts index a00e6d6f8c..0889ac34da 100644 --- a/src/platform/core/chips/chips.component.spec.ts +++ b/src/platform/core/chips/chips.component.spec.ts @@ -3,15 +3,27 @@ import { async, ComponentFixture, } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Component, DebugElement } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule, } from '@angular/forms'; -import { OverlayContainer, MdInputDirective, DOWN_ARROW, UP_ARROW, ENTER, SPACE, TAB } from '@angular/material'; +import { OverlayContainer, MdInputDirective, MdChip, BACKSPACE, ENTER, LEFT_ARROW, RIGHT_ARROW } from '@angular/material'; import { By } from '@angular/platform-browser'; import { CovalentChipsModule, TdChipsComponent } from './chips.module'; +function createFakeKeyboardEvent(keyCode: number): any { + return { + keyCode: keyCode, + preventDefault: function(): void { + /* noop */ + }, + stopPropagation: function(): void { + /* noop */ + }, + }; +} + describe('Component: Chips', () => { let overlayContainerElement: HTMLElement; @@ -21,10 +33,12 @@ describe('Component: Chips', () => { CovalentChipsModule, FormsModule, ReactiveFormsModule, - BrowserAnimationsModule, + NoopAnimationsModule, ], declarations: [ + TdChipsA11yTestComponent, TdChipsBasicTestComponent, + TdChipsObjectsRequireMatchTestComponent, ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -44,6 +58,228 @@ describe('Component: Chips', () => { TestBed.compileComponents(); })); + describe('a11y keyboard in chips and input: ', () => { + let fixture: ComponentFixture; + let input: DebugElement; + let chips: DebugElement; + + beforeEach(() => { + fixture = TestBed.createComponent(TdChipsA11yTestComponent); + fixture.detectChanges(); + + chips = fixture.debugElement.query(By.directive(TdChipsComponent)); + input = chips.query(By.css('input')); + }); + + it('should focus input', (done: DoneFn) => { + fixture.componentInstance.chipAddition = true; + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.nativeElement.focus(); + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect((chips.componentInstance)._inputChild.focused).toBeTruthy(); + done(); + }); + }); + }); + + it('should focus first chip', (done: DoneFn) => { + fixture.componentInstance.chipAddition = false; + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.nativeElement.focus(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement) + .toBe(document.activeElement); + done(); + }); + }); + }); + }); + + it('should focus around the chips going left', (done: DoneFn) => { + fixture.componentInstance.chipAddition = false; + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.nativeElement.focus(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[0] + .triggerEventHandler('keydown', createFakeKeyboardEvent(LEFT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[2].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[2] + .triggerEventHandler('keydown', createFakeKeyboardEvent(LEFT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[1].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[1] + .triggerEventHandler('keydown', createFakeKeyboardEvent(LEFT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement) + .toBe(document.activeElement); + done(); + }); + }); + }); + }); + }); + }); + }); + + it('should focus around the chips going right', (done: DoneFn) => { + fixture.componentInstance.chipAddition = false; + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.nativeElement.focus(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[0] + .triggerEventHandler('keydown', createFakeKeyboardEvent(RIGHT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[1].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[1] + .triggerEventHandler('keydown', createFakeKeyboardEvent(RIGHT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[2].nativeElement) + .toBe(document.activeElement); + fixture.debugElement.queryAll(By.directive(MdChip))[2] + .triggerEventHandler('keydown', createFakeKeyboardEvent(RIGHT_ARROW)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement) + .toBe(document.activeElement); + done(); + }); + }); + }); + }); + }); + }); + }); + + }); + + describe('panel usage and add/removal: ', () => { + let fixture: ComponentFixture; + let input: DebugElement; + let chips: DebugElement; + + beforeEach(() => { + fixture = TestBed.createComponent(TdChipsBasicTestComponent); + fixture.detectChanges(); + + chips = fixture.debugElement.query(By.directive(TdChipsComponent)); + input = chips.query(By.css('input')); + }); + + it('should set a value in the input and enter it as chip', (done: DoneFn) => { + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + (chips.componentInstance)._inputChild.value = 'test'; + fixture.detectChanges(); + fixture.whenStable().then(() => { + input.triggerEventHandler('keyup.enter', createFakeKeyboardEvent(ENTER)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + // set tiemout + setTimeout(() => { + expect(chips.componentInstance.value.length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip))[0].nativeElement.textContent).toContain('test'); + done(); + }, 200); + }); + }); + }); + }); + + it('should open the panel, click on an option and add it as chip', (done: DoneFn) => { + fixture.componentInstance.filter(''); + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const option: HTMLElement = overlayContainerElement.querySelector('md-option'); + option.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(chips.componentInstance.value.length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(1); + done(); + }); + }); + }); + + it('should open the panel, click on an option to add it as chip and remove it with backspace', (done: DoneFn) => { + fixture.componentInstance.filter(''); + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const option: HTMLElement = overlayContainerElement.querySelector('md-option'); + option.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(chips.componentInstance.value.length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(1); + fixture.debugElement.queryAll(By.directive(MdChip))[0].triggerEventHandler('keydown', createFakeKeyboardEvent(BACKSPACE)); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(chips.componentInstance.value.length).toBe(0); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(0); + done(); + }); + }); + }); + }); + + it('should open the panel, click on an option to add it as chip and remove it by clicking on the remove button', (done: DoneFn) => { + fixture.componentInstance.filter(''); + chips.triggerEventHandler('focus', new Event('focus')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const option: HTMLElement = overlayContainerElement.querySelector('md-option'); + option.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(chips.componentInstance.value.length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(1); + fixture.debugElement.queryAll(By.css('.td-chip-removal'))[0].triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(chips.componentInstance.value.length).toBe(0); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(0); + done(); + }); + }); + }); + }); + + }); + describe('panel usage and filtering: ', () => { let fixture: ComponentFixture; let input: DebugElement; @@ -53,7 +289,6 @@ describe('Component: Chips', () => { fixture = TestBed.createComponent(TdChipsBasicTestComponent); fixture.detectChanges(); - input = fixture.debugElement.query(By.css('input')); chips = fixture.debugElement.query(By.directive(TdChipsComponent)); }); @@ -65,20 +300,18 @@ describe('Component: Chips', () => { expect(overlayContainerElement.textContent).not.toContain('chips'); expect(overlayContainerElement.textContent).not.toContain('pasta'); expect(overlayContainerElement.textContent).not.toContain('sushi'); - input.triggerEventHandler('focus', new Event('focus')); + fixture.componentInstance.filter(''); + chips.triggerEventHandler('focus', new Event('focus')); fixture.detectChanges(); fixture.whenStable().then(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).toContain('steak'); - expect(overlayContainerElement.textContent).toContain('pizza'); - expect(overlayContainerElement.textContent).toContain('tacos'); - expect(overlayContainerElement.textContent).toContain('sandwich'); - expect(overlayContainerElement.textContent).toContain('chips'); - expect(overlayContainerElement.textContent).toContain('pasta'); - expect(overlayContainerElement.textContent).toContain('sushi'); - done(); - }); + expect(overlayContainerElement.textContent).toContain('steak'); + expect(overlayContainerElement.textContent).toContain('pizza'); + expect(overlayContainerElement.textContent).toContain('tacos'); + expect(overlayContainerElement.textContent).toContain('sandwich'); + expect(overlayContainerElement.textContent).toContain('chips'); + expect(overlayContainerElement.textContent).toContain('pasta'); + expect(overlayContainerElement.textContent).toContain('sushi'); + done(); }); }); @@ -90,86 +323,81 @@ describe('Component: Chips', () => { expect(overlayContainerElement.textContent).not.toContain('chips'); expect(overlayContainerElement.textContent).not.toContain('pasta'); expect(overlayContainerElement.textContent).not.toContain('sushi'); - input.triggerEventHandler('focus', new Event('focus')); + fixture.componentInstance.filter(''); + chips.triggerEventHandler('focus', new Event('focus')); fixture.detectChanges(); fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent).toContain('steak'); + expect(overlayContainerElement.textContent).toContain('pizza'); + expect(overlayContainerElement.textContent).toContain('tacos'); + expect(overlayContainerElement.textContent).toContain('sandwich'); + expect(overlayContainerElement.textContent).toContain('chips'); + expect(overlayContainerElement.textContent).toContain('pasta'); + expect(overlayContainerElement.textContent).toContain('sushi'); + (chips.componentInstance).inputControl.setValue('a'); fixture.detectChanges(); fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).toContain('steak'); - expect(overlayContainerElement.textContent).toContain('pizza'); - expect(overlayContainerElement.textContent).toContain('tacos'); - expect(overlayContainerElement.textContent).toContain('sandwich'); - expect(overlayContainerElement.textContent).toContain('chips'); - expect(overlayContainerElement.textContent).toContain('pasta'); - expect(overlayContainerElement.textContent).toContain('sushi'); - (chips.componentInstance).inputControl.setValue('a'); - fixture.detectChanges(); - fixture.whenStable().then(() => { - setTimeout(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).toContain('steak'); - expect(overlayContainerElement.textContent).toContain('pizza'); - expect(overlayContainerElement.textContent).toContain('tacos'); - expect(overlayContainerElement.textContent).toContain('sandwich'); - expect(overlayContainerElement.textContent).not.toContain('chips'); - expect(overlayContainerElement.textContent).toContain('pasta'); - expect(overlayContainerElement.textContent).not.toContain('sushi'); - done(); - }); - }, 100); - }); + // mimic debounce + setTimeout(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent).toContain('steak'); + expect(overlayContainerElement.textContent).toContain('pizza'); + expect(overlayContainerElement.textContent).toContain('tacos'); + expect(overlayContainerElement.textContent).toContain('sandwich'); + expect(overlayContainerElement.textContent).not.toContain('chips'); + expect(overlayContainerElement.textContent).toContain('pasta'); + expect(overlayContainerElement.textContent).not.toContain('sushi'); + done(); + }); + }, 200); }); }); }); + }); - it('should open the panel, filter selectedItems and filter the list', (done: DoneFn) => { - expect(overlayContainerElement.textContent).not.toContain('steak'); - expect(overlayContainerElement.textContent).not.toContain('pizza'); - expect(overlayContainerElement.textContent).not.toContain('tacos'); - expect(overlayContainerElement.textContent).not.toContain('sandwich'); - expect(overlayContainerElement.textContent).not.toContain('chips'); - expect(overlayContainerElement.textContent).not.toContain('pasta'); - expect(overlayContainerElement.textContent).not.toContain('sushi'); - fixture.componentInstance.selectedItems.push('steak'); - fixture.componentInstance.selectedItems.push('sandwich'); - input.triggerEventHandler('focus', new Event('focus')); + describe('panel usage and requireMatch usage: ', () => { + let fixture: ComponentFixture; + let input: DebugElement; + let chips: DebugElement; + + beforeEach(() => { + fixture = TestBed.createComponent(TdChipsObjectsRequireMatchTestComponent); + fixture.detectChanges(); + + chips = fixture.debugElement.query(By.directive(TdChipsComponent)); + }); + + it('should open the panel, click on an option to add it as chip', (done: DoneFn) => { + fixture.componentInstance.objects = [{ + name: 'San Diego', + }, { + name: 'Los Angeles', + }]; + chips.triggerEventHandler('focus', new Event('focus')); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).not.toContain('steak'); - expect(overlayContainerElement.textContent).toContain('pizza'); - expect(overlayContainerElement.textContent).toContain('tacos'); - expect(overlayContainerElement.textContent).not.toContain('sandwich'); - expect(overlayContainerElement.textContent).toContain('chips'); - expect(overlayContainerElement.textContent).toContain('pasta'); - expect(overlayContainerElement.textContent).toContain('sushi'); - (chips.componentInstance).inputControl.setValue('a'); + const option: HTMLElement = overlayContainerElement.querySelector('md-option'); + option.click(); fixture.detectChanges(); fixture.whenStable().then(() => { - setTimeout(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(overlayContainerElement.textContent).not.toContain('steak'); - expect(overlayContainerElement.textContent).toContain('pizza'); - expect(overlayContainerElement.textContent).toContain('tacos'); - expect(overlayContainerElement.textContent).not.toContain('sandwich'); - expect(overlayContainerElement.textContent).not.toContain('chips'); - expect(overlayContainerElement.textContent).toContain('pasta'); - expect(overlayContainerElement.textContent).not.toContain('sushi'); - done(); - }); - }, 100); + expect(chips.componentInstance.value.length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(MdChip)).length).toBe(1); + done(); }); }); }); }); + }); // TODO - // requireMatch usage + // more requireMatch usage + + // more a11y unit tests // readOnly usage @@ -185,11 +413,31 @@ describe('Component: Chips', () => { @Component({ template: ` - + + `, +}) +class TdChipsA11yTestComponent { + chipAddition: boolean = true; + items: string[] = [ + 'steak', + 'pizza', + 'tacos', + 'sandwich', + 'chips', + 'pasta', + 'sushi', + ]; + selectedItems: string[] = this.items.slice(0, 3); +} + +@Component({ + template: ` + `, }) class TdChipsBasicTestComponent { placeholder: string; + filteredItems: string[]; selectedItems: string[] = []; items: string[] = [ 'steak', @@ -200,4 +448,27 @@ class TdChipsBasicTestComponent { 'pasta', 'sushi', ]; + filter(value: string): void { + this.filteredItems = this.items.filter((item: any) => { + return item.toLowerCase().indexOf(value.toLowerCase()) > -1; + }).filter((filteredItem: any) => { + return this.selectedItems ? this.selectedItems.indexOf(filteredItem) < 0 : true; + }); + } +} + +@Component({ + template: ` + + + {{chip.name}} + + + {{option.name}} + + `, +}) +class TdChipsObjectsRequireMatchTestComponent { + selectedObjects: any[] = []; + objects: any[]; } diff --git a/src/platform/core/chips/chips.component.ts b/src/platform/core/chips/chips.component.ts index 419da5af4a..f7dd7898f4 100644 --- a/src/platform/core/chips/chips.component.ts +++ b/src/platform/core/chips/chips.component.ts @@ -1,51 +1,84 @@ -import { Component, Input, Output, forwardRef, DoCheck, ViewChild, ViewChildren, QueryList, OnInit, HostListener } from '@angular/core'; +import { Component, Input, Output, forwardRef, DoCheck, ViewChild, ViewChildren, QueryList, OnInit, HostListener, + ElementRef, Optional, Inject, Directive, TemplateRef, ViewContainerRef, ContentChild, ChangeDetectionStrategy, + ChangeDetectorRef, AfterViewInit, OnDestroy, HostBinding } from '@angular/core'; +import { DOCUMENT } from '@angular/platform-browser'; import { EventEmitter } from '@angular/core'; import { NG_VALUE_ACCESSOR, ControlValueAccessor, FormControl } from '@angular/forms'; -import { MdChip, MdInputDirective, ESCAPE, LEFT_ARROW, RIGHT_ARROW, DELETE, BACKSPACE } from '@angular/material'; +import { MdChip, MdInputDirective, TemplatePortalDirective, MdOption, MdAutocompleteTrigger, UP_ARROW, DOWN_ARROW, + ESCAPE, LEFT_ARROW, RIGHT_ARROW, DELETE, BACKSPACE, ENTER, SPACE, TAB, HOME } from '@angular/material'; import { Observable } from 'rxjs/Observable'; -import { Subject } from 'rxjs/Subject'; +import { Subscription } from 'rxjs/Subscription'; import 'rxjs/add/observable/timer'; +import 'rxjs/add/operator/toPromise'; import 'rxjs/add/operator/debounceTime'; const noop: any = () => { // empty method }; -export const TD_CHIPS_CONTROL_VALUE_ACCESSOR: any = { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TdChipsComponent), - multi: true, -}; +@Directive({ + selector: '[td-basic-chip]ng-template', +}) +export class TdBasicChipDirective extends TemplatePortalDirective { + constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { + super(templateRef, viewContainerRef); + } +} + +@Directive({ + selector: '[td-autocomplete-option]ng-template', +}) +export class TdAutocompleteOptionDirective extends TemplatePortalDirective { + constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { + super(templateRef, viewContainerRef); + } +} @Component({ - providers: [ TD_CHIPS_CONTROL_VALUE_ACCESSOR ], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TdChipsComponent), + multi: true, + }], selector: 'td-chips', styleUrls: ['./chips.component.scss' ], templateUrl: './chips.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { +export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit, AfterViewInit, OnDestroy { + + private _outsideClickSubs: Subscription; /** * Implemented as part of ControlValueAccessor. */ - private _value: any = []; + private _value: any[] = []; + private _items: any[]; private _length: number = 0; private _requireMatch: boolean = false; private _readOnly: boolean = false; private _chipAddition: boolean = true; + private _focused: boolean = false; + private _tabIndex: number = 0; + + _internalClick: boolean = false; @ViewChild(MdInputDirective) _inputChild: MdInputDirective; + @ViewChild(MdAutocompleteTrigger) _autocompleteTrigger: MdAutocompleteTrigger; @ViewChildren(MdChip) _chipsChildren: QueryList; - /** - * Boolean value that specifies if the input is valid against the provieded list. - */ - matches: boolean = true; + @ContentChild(TdBasicChipDirective) _basicChipTemplate: TdBasicChipDirective; + @ContentChild(TdAutocompleteOptionDirective) _autocompleteOptionTemplate: TdAutocompleteOptionDirective; + + @ViewChildren(MdOption) _options: QueryList; + /** * Flag that is true when autocomplete is focused. */ - focused: boolean = false; + get focused(): boolean { + return this._focused; + } /** * FormControl for the mdInput element. @@ -53,25 +86,22 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { inputControl: FormControl = new FormControl(); /** - * Subject to control what items to render in the autocomplete - */ - subject: Subject = new Subject(); - - /** - * Observable of items to render in the autocomplete + * items?: any[] + * Renders the `md-autocomplete` with the provided list to display as options. */ - filteredItems: Observable = this.subject.asObservable(); - - /** - * items?: string[] - * Enables Autocompletion with the provided list of strings. - */ - @Input('items') items: string[] = []; + @Input('items') + set items(items: any[]) { + this._items = items; + this._setFirstOptionActive(); + this._changeDetectorRef.markForCheck(); + } + get items(): any[] { + return this._items; + } /** * requireMatch?: boolean - * Validates input against the provided list before adding it to the model. - * If it doesnt exist, it cancels the event. + * Blocks custom inputs and only allows selections from the autocomplete list. */ @Input('requireMatch') set requireMatch(requireMatch: any) { @@ -96,8 +126,8 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { /** * chipAddition?: boolean - * Disables the ability to add chips. If it doesn't exist chip addition defaults to true. - * When setting readOnly as true, this will be overriden. + * Disables the ability to add chips. When setting readOnly as true, this will be overriden. + * Defaults to true. */ @Input('chipAddition') set chipAddition(chipAddition: boolean) { @@ -122,19 +152,32 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { */ @Input('placeholder') placeholder: string; + /** + * debounce?: number + * Debounce timeout between keypresses. Defaults to 200. + */ + @Input('debounce') debounce: number = 200; + /** * add?: function - * Method to be executed when string is added as chip through the autocomplete. + * Method to be executed when a chip is added. * Sends chip value as event. */ - @Output('add') add: EventEmitter = new EventEmitter(); + @Output('add') onAdd: EventEmitter = new EventEmitter(); /** * remove?: function - * Method to be executed when string is removed as chip with the "remove" button. + * Method to be executed when a chip is removed. * Sends chip value as event. */ - @Output('remove') remove: EventEmitter = new EventEmitter(); + @Output('remove') onRemove: EventEmitter = new EventEmitter(); + + /** + * inputChange?: function + * Method to be executed when the value in the autocomplete input changes. + * Sends string value as event. + */ + @Output('inputChange') onInputChange: EventEmitter = new EventEmitter(); /** * Implemented as part of ControlValueAccessor. @@ -143,24 +186,78 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { if (v !== this._value) { this._value = v; this._length = this._value ? this._value.length : 0; - if (this._value) { - this._filter(this.inputControl.value); - } } } get value(): any { return this._value; } + /** + * Hostbinding to set the a11y of the TdChipsComponent depending on its state + */ + @HostBinding('attr.tabindex') + get tabIndex(): number { + return this.readOnly ? -1 : this._tabIndex; + } + + constructor(private _elementRef: ElementRef, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(DOCUMENT) private _document: any) {} + + /** + * Listens to host focus event to act on it + */ + @HostListener('focus', ['$event']) + focusListener(event: FocusEvent): void { + this.focus(); + event.preventDefault(); + } + + /** + * If clicking on :host or `td-chips-wrapper`, then we stop the click propagation so the autocomplete + * doesnt close automatically. + */ + @HostListener('click', ['$event']) + clickListener(event: Event): void { + const clickTarget: HTMLElement = event.target; + if (clickTarget === this._elementRef.nativeElement || + clickTarget.className.indexOf('td-chips-wrapper') > -1) { + event.preventDefault(); + event.stopPropagation(); + } + } + + /** + * Listens to host keydown event to act on it depending on the keypress + */ + @HostListener('keydown', ['$event']) + keydownListener(event: KeyboardEvent): void { + switch (event.keyCode) { + case TAB: + // if tabing out, then unfocus the component + Observable.timer().toPromise().then(() => { + this.removeFocusedState(); + }); + break; + case ESCAPE: + case HOME: + this.focus(); + break; + default: + // default + } + } + ngOnInit(): void { this.inputControl.valueChanges - .debounceTime(100) + .debounceTime(this.debounce) .subscribe((value: string) => { - this.matches = true; - this._filter(value); + this.onInputChange.emit(value ? value : ''); }); - // filter the autocomplete options after everything is rendered - Observable.timer().subscribe(() => { - this._filter(this.inputControl.value); - }); + this._changeDetectorRef.markForCheck(); + } + + ngAfterViewInit(): void { + this._watchOutsideClick(); + this._changeDetectorRef.markForCheck(); } ngDoCheck(): void { @@ -171,35 +268,72 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { } } - /** - * Returns a list of filtered items. - */ - filter(val: string): string[] { - return this.items.filter((item: string) => { - return val ? item.indexOf(val) > -1 : true; - }); + ngOnDestroy(): void { + if (this._outsideClickSubs) { + this._outsideClickSubs.unsubscribe(); + this._outsideClickSubs = undefined; + } } /** * Method that is executed when trying to create a new chip from the autocomplete. + * It check if [requireMatch] is enabled, and tries to add the first active option + * else if just adds the value thats on the input * returns 'true' if successful, 'false' if it fails. */ - addChip(value: string): boolean { - if (value.trim() === '' || this._value.indexOf(value) > -1) { - this.matches = false; - return false; - } - if (this.items && this.requireMatch) { - if (this.items.indexOf(value) < 0) { - this.matches = false; + _handleAddChip(): boolean { + let value: any; + if (this.requireMatch) { + let selectedOptions: MdOption[] = this._options.toArray().filter((option: MdOption) => { + return option.active; + }); + if (selectedOptions.length > 0) { + value = selectedOptions[0].value; + selectedOptions[0].setInactiveStyles(); + } + if (!value) { return false; } + } else { + // if there is a selection, then use that + // else use the input value as chip + if (this._autocompleteTrigger.activeOption) { + value = this._autocompleteTrigger.activeOption.value; + this._autocompleteTrigger.activeOption.setInactiveStyles(); + } else { + value = this._inputChild.value; + if (value.trim() === '') { + return false; + } + } + } + return this.addChip(value); + } + + /** + * Method thats exectuted when trying to add a value as chip + * returns 'true' if successful, 'false' if it fails. + */ + addChip(value: any): boolean { + this.inputControl.setValue(''); + // check if value is already part of the model + if (this._value.indexOf(value) > -1) { + return false; } this._value.push(value); - this.add.emit(value); + this.onAdd.emit(value); this.onChange(this._value); - this.inputControl.setValue(''); - this.matches = true; + this._changeDetectorRef.markForCheck(); + /** + * add a 200 ms delay when reopening the autocomplete to give it time + * to rerender the next list and at the correct spot + */ + this._closeAutocomplete(); + Observable.timer(200).toPromise().then(() => { + this.setFocusedState(); + this._setFirstOptionActive(); + this._openAutocomplete(); + }); return true; } @@ -207,36 +341,66 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { * Method that is executed when trying to remove a chip. * returns 'true' if successful, 'false' if it fails. */ - removeChip(value: string): boolean { - let index: number = this._value.indexOf(value); - if (index < 0) { + removeChip(index: number): boolean { + let removedValues: any[] = this._value.splice(index, 1); + if (removedValues.length === 0) { return false; } - this._value.splice(index, 1); - this.remove.emit(value); + + /** + * Checks if deleting last single chip, to focus input afterwards + * Else check if its not the last chip of the list to focus the next one. + */ + if (index === (this._totalChips - 1) && index === 0) { + this._inputChild.focus(); + } else if (index < (this._totalChips - 1)) { + this._focusChip(index + 1); + } else if (index > 0) { + this._focusChip(index - 1); + } + + this.onRemove.emit(removedValues[0]); this.onChange(this._value); this.inputControl.setValue(''); + this._changeDetectorRef.markForCheck(); return true; } - handleFocus(): boolean { - this.focused = true; + _handleFocus(): boolean { + this.setFocusedState(); + this._setFirstOptionActive(); return true; } - handleBlur(): boolean { - this.focused = false; - this.matches = true; - this.onTouched(); - return true; + /** + * Sets focus state of the component + */ + setFocusedState(): void { + if (!this.readOnly) { + this._focused = true; + this._tabIndex = -1; + this._changeDetectorRef.markForCheck(); + } } /** - * Programmatically focus the input. Since its the component entry point + * Removes focus state of the component + */ + removeFocusedState(): void { + this._focused = false; + this._tabIndex = 0; + this._changeDetectorRef.markForCheck(); + } + + /** + * Programmatically focus the input or first chip. Since its the component entry point + * depending if a user can add or remove chips */ focus(): void { if (this.canAddChip) { this._inputChild.focus(); + } else if (!this.readOnly) { + this._focusFirstChip(); } } @@ -245,9 +409,23 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { */ _inputKeydown(event: KeyboardEvent): void { switch (event.keyCode) { + case UP_ARROW: + /** + * Since the first item is highlighted on [requireMatch], we need to inactivate it + * when pressing the up key + */ + if (this.requireMatch) { + let length: number = this._options.length; + if (length > 0 && this._options.toArray()[0].active) { + this._options.toArray()[0].setInactiveStyles(); + event.preventDefault(); + } + } + break; case LEFT_ARROW: case DELETE: case BACKSPACE: + this._closeAutocomplete(); /** Check to see if input is empty when pressing left arrow to move to the last chip */ if (!this._inputChild.value) { this._focusLastChip(); @@ -255,6 +433,7 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { } break; case RIGHT_ARROW: + this._closeAutocomplete(); /** Check to see if input is empty when pressing right arrow to move to the first chip */ if (!this._inputChild.value) { this._focusFirstChip(); @@ -275,16 +454,7 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { case BACKSPACE: /** Check to see if not in [readOnly] state to delete a chip */ if (!this.readOnly) { - /** - * Checks if deleting last single chip, to focus input afterwards - * Else check if its not the last chip of the list to focus the next one. - */ - if (index === (this._totalChips - 1) && index === 0) { - this.focus(); - } else if (index < (this._totalChips - 1)) { - this._focusChip(index + 1); - } - this.removeChip(this.value[index]); + this.removeChip(index); } break; case LEFT_ARROW: @@ -292,29 +462,65 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { * Check to see if left arrow was pressed while focusing the first chip to focus input next * Also check if input should be focused */ - if (index === 0 && this.canAddChip) { - this.focus(); - event.stopPropagation(); + if (index === 0) { + if (this.canAddChip) { + this._inputChild.focus(); + } else { + this._focusLastChip(); + } + } else if (index > 0) { + this._focusChip(index - 1); } + event.stopPropagation(); break; case RIGHT_ARROW: /** * Check to see if right arrow was pressed while focusing the last chip to focus input next * Also check if input should be focused */ - if (index === (this._totalChips - 1) && this.canAddChip) { - this.focus(); - event.stopPropagation(); + if (index === (this._totalChips - 1)) { + if (this.canAddChip) { + this._inputChild.focus(); + } else { + this._focusFirstChip(); + } + } else if (index < (this._totalChips - 1)) { + this._focusChip(index + 1); } - break; - case ESCAPE: - this.focus(); + event.stopPropagation(); break; default: // default } } + /** + * Method to remove from display the value added from the autocomplete since it goes directly as chip. + */ + _removeInputDisplay(): string { + return ''; + } + + /** + * Method to open the autocomplete manually if its not already opened + */ + _openAutocomplete(): void { + if (!this._autocompleteTrigger.panelOpen) { + this._autocompleteTrigger.openPanel(); + this._changeDetectorRef.markForCheck(); + } + } + + /** + * Method to close the autocomplete manually if its not already closed + */ + _closeAutocomplete(): void { + if (this._autocompleteTrigger.panelOpen) { + this._autocompleteTrigger.closePanel(); + this._changeDetectorRef.markForCheck(); + } + } + /** * Implemented as part of ControlValueAccessor. */ @@ -333,18 +539,6 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { onChange = (_: any) => noop; onTouched = () => noop; - /** - * - * Method to filter the options for the autocomplete - */ - private _filter(value: string): void { - let items: string[] = this.filter(value); - items = items.filter((filteredItem: string) => { - return this._value && filteredItem ? this._value.indexOf(filteredItem) < 0 : true; - }); - this.subject.next(items); - } - /** * Get total of chips */ @@ -383,5 +577,53 @@ export class TdChipsComponent implements ControlValueAccessor, DoCheck, OnInit { } else { this.inputControl.disable(); } + this._changeDetectorRef.markForCheck(); + } + + /** + * Sets first option as active to let the user know which one will be added when pressing enter + * Only if [requireMatch] has been set + */ + private _setFirstOptionActive(): void { + if (this.requireMatch) { + // need to use a timer here to wait until the autocomplete has been opened (end of queue) + Observable.timer().toPromise().then(() => { + if (this.focused && this._options && this._options.length > 0) { + // clean up of previously active options + this._options.toArray().forEach((option: MdOption) => { + option.setInactiveStyles(); + }); + // set the first one as active + this._options.toArray()[0].setActiveStyles(); + this._changeDetectorRef.markForCheck(); + } + }); + } + } + + /** + * Watches clicks outside of the component to remove the focus + * The autocomplete panel is considered inside the component so we + * need to use a flag to find out when its clicked. + */ + private _watchOutsideClick(): void { + if (this._document) { + this._outsideClickSubs = Observable.fromEvent(this._document, 'click').filter((event: MouseEvent) => { + const clickTarget: HTMLElement = event.target; + setTimeout(() => { + this._internalClick = false; + }); + return this.focused && + (clickTarget !== this._elementRef.nativeElement) && + !this._elementRef.nativeElement.contains(clickTarget) && !this._internalClick; + }).subscribe(() => { + if (this.focused) { + this.removeFocusedState(); + this.onTouched(); + this._changeDetectorRef.markForCheck(); + } + }); + } + return undefined; } } diff --git a/src/platform/core/chips/chips.module.ts b/src/platform/core/chips/chips.module.ts index 90bbb29763..5dad8aa629 100644 --- a/src/platform/core/chips/chips.module.ts +++ b/src/platform/core/chips/chips.module.ts @@ -5,8 +5,8 @@ import { CommonModule } from '@angular/common'; import { MdInputModule, MdIconModule, MdAutocompleteModule, MdChipsModule } from '@angular/material'; -import { TdChipsComponent } from './chips.component'; -export { TdChipsComponent } from './chips.component'; +import { TdChipsComponent, TdBasicChipDirective, TdAutocompleteOptionDirective } from './chips.component'; +export { TdChipsComponent, TdBasicChipDirective, TdAutocompleteOptionDirective } from './chips.component'; @NgModule({ imports: [ @@ -19,9 +19,13 @@ export { TdChipsComponent } from './chips.component'; ], declarations: [ TdChipsComponent, + TdBasicChipDirective, + TdAutocompleteOptionDirective, ], exports: [ TdChipsComponent, + TdBasicChipDirective, + TdAutocompleteOptionDirective, ], }) export class CovalentChipsModule {