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 {