Skip to content

Commit

Permalink
feat(typeahead): add grouping of typeahead options
Browse files Browse the repository at this point in the history
- Add typeaheadGroupField @input for configurable groupBy
- Add template rendering for grouped matches
- Extend Demo with grouped example
  • Loading branch information
mixomat authored and valorkin committed Oct 7, 2016
1 parent 8df986b commit fdddbde
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 73 deletions.
4 changes: 3 additions & 1 deletion components/typeahead/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class TypeaheadDirective implements OnInit {
@Input() public typeaheadWaitMs:number;
@Input() public typeaheadOptionsLimit:number;
@Input() public typeaheadOptionField:string;
@Input() public typeaheadGroupField:string;
@Input() public typeaheadAsync:boolean = null;
@Input() public typeaheadLatinize:boolean = true;
@Input() public typeaheadSingleWords:boolean = true;
Expand All @@ -46,7 +47,8 @@ export class TypeaheadDirective implements OnInit {
- `typeaheadMinLength` (`?number=1`) - minimal no of characters that needs to be entered before typeahead kicks-in. When set to 0, typeahead shows on focus with full list of options (limited as normal by typeaheadOptionsLimit)
- `typeaheadWaitMs` (`?number=0`) - minimal wait time after last character typed before typeahead kicks-in
- `typeaheadOptionsLimit` (`?number=20`) - maximum length of options items list
- `typeaheadOptionField` (`?string`) - name of field in array of states that contain options as objects, we use array item as option in case of this field is missing. Supports nested properties and methods
- `typeaheadOptionField` (`?string`) - when options source is an array of objects, the name of field that contains the options value, we use array item as option in case of this field is missing. Supports nested properties and methods.
- `typeaheadGroupField` (`?string`) - when options source is an array of objects, the name of field that contains the group value, matches are grouped by this field when set.
- `typeaheadAsync` (`?boolean`) - should be used only in case of `typeahead` attribute is array. If `true` - loading of options will be async, otherwise - sync. `true` make sense if options array is large.
- `typeaheadLatinize` (`?boolean=true`) - match latin symbols. If `true` the word `súper` would match `super` and vice versa.
- `typeaheadSingleWords` (`?boolean=true`) - break words with spaces. If `true` the text `"exact phrase" here match` would match with `match exact phrase here` but not with `phrase here exact match` (kind of "google style").
Expand Down
91 changes: 91 additions & 0 deletions components/typeahead/typeahead-container.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TypeaheadContainerComponent } from './typeahead-container.component';
import { TypeaheadOptions } from './typeahead-options.class';
import { asNativeElements } from '@angular/core';

describe('Component: TypeaheadContainer', () => {
let fixture:ComponentFixture<TypeaheadContainerComponent>;
let component:TypeaheadContainerComponent;

beforeEach(() => {
fixture = TestBed.configureTestingModule({
declarations: [TypeaheadContainerComponent],
providers: [{
provide: TypeaheadOptions,
useValue: new TypeaheadOptions({animation: false, placement: 'bottom-left', typeaheadRef: undefined})
}]
}).createComponent(TypeaheadContainerComponent);

component = fixture.componentInstance;
fixture.detectChanges();
});

it('should be defined', () => {
expect(component).toBeTruthy();
});

it('should have an \"element\" property', () => {
expect(component.element).toBeTruthy();
});

it('should have an empty \"matches\" array', () => {
expect(component.matches.length).toBe(0);
});

describe('dropdown-menu', () => {
let dropDown:HTMLElement;

beforeEach(() => {
component.position(fixture.elementRef);
fixture.detectChanges();

dropDown = fixture.debugElement.query(By.css('.dropdown-menu')).nativeElement as HTMLElement;
});

it('should be rendered', () => {
expect(dropDown).toBeDefined();
});

it('should have display style set', () => {
expect(dropDown.style.display).toBe('block');
});

it('should have top style set', () => {
expect(dropDown.style.top).toBe('16px');
});

it('should have left style set', () => {
expect(dropDown.style.left).toBe('8px');
});
});

describe('matches', () => {
let matches:HTMLLIElement[];

beforeEach(() => {
component.query = 'fo';
component.matches = ['foo', 'food'];
fixture.detectChanges();

matches = asNativeElements(fixture.debugElement.queryAll(By.css('.dropdown-menu li')));
});

it('should render 2 matches', () => {
expect(matches.length).toBe(2);
});

it('should highlight query for match', () => {
expect(matches[1].children[0].innerHTML).toBe('<strong>fo</strong>od');
});

it('should set the \"active\" class on the first match', () => {
expect(matches[0].classList.contains('active')).toBeTruthy();
});

it('should not set the \"active\" class on other matches', () => {
expect(matches[1].classList.contains('active')).toBeFalsy();
});
});

});
123 changes: 95 additions & 28 deletions components/typeahead/typeahead-container.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,84 @@ import { TypeaheadDirective } from './typeahead.directive';

const bs4 = `
<div class="dropdown-menu"
style="display: block"
[ngStyle]="{top: top, left: left, display: display}"
[ngStyle]="{top: top, left: left, display: 'block'}"
(mouseleave)="focusLost()">
<div *ngIf="!itemTemplate">
<template *ngIf="hasGroups()" ngFor let-group [ngForOf]="groups">
<h6 class="dropdown-header">{{group}}</h6>
<div *ngIf="!itemTemplate">
<a href="#"
*ngFor="let match of matchesByGroup(group)"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)"
[innerHtml]="hightlight(match, query)"></a>
</div>
<div *ngIf="itemTemplate">
<a href="#"
*ngFor="let match of matches"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)"
[innerHtml]="hightlight(match, query)"></a>
</div>
<div *ngIf="itemTemplate">
<a href="#"
*ngFor="let match of matches; let i = index"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)">
<template [ngTemplateOutlet]="itemTemplate"
[ngOutletContext]="{item: match, index: i}">
</template>
</a>
</div>
*ngFor="let match of matchesByGroup(group); let i = index"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)">
<template [ngTemplateOutlet]="itemTemplate"
[ngOutletContext]="{item: match, index: i}">
</template>
</a>
</div>
</template>
<template [ngIf]="!hasGroups()">
<div *ngIf="!itemTemplate">
<a href="#"
*ngFor="let match of matches"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)"
[innerHtml]="hightlight(match, query)"></a>
</div>
<div *ngIf="itemTemplate">
<a href="#"
*ngFor="let match of matches; let i = index"
class="dropdown-item"
(click)="selectMatch(match, $event)"
(mouseenter)="selectActive(match)"
[class.active]="isActive(match)">
<template [ngTemplateOutlet]="itemTemplate"
[ngOutletContext]="{item: match, index: i}">
</template>
</a>
</div>
</template>
</div>
`;

const bs3 = `
<ul class="dropdown-menu"
style="display: block"
[ngStyle]="{top: top, left: left, display: display}"
[ngStyle]="{top: top, left: left, display: 'block'}"
(mouseleave)="focusLost()">
<li *ngFor="let match of matches; let i = index"
<template *ngIf="hasGroups()" ngFor let-group [ngForOf]="groups">
<li class="dropdown-header">{{group}}</li>
<li *ngFor="let match of matchesByGroup(group); let i = index"
[class.active]="isActive(match)"
(mouseenter)="selectActive(match)">
<a href="#"
*ngIf="!itemTemplate"
(click)="selectMatch(match, $event)"
tabindex="-1"
[innerHtml]="hightlight(match, query)"></a>
<a href="#"
*ngIf="itemTemplate"
(click)="selectMatch(match, $event)"
tabindex="-1">
<template [ngTemplateOutlet]="itemTemplate"
[ngOutletContext]="{item: match, index: i}">
</template>
</a>
</li>
</template>
<template [ngIf]="!hasGroups()">
<li *ngFor="let match of matches; let i = index"
[class.active]="isActive(match)"
(mouseenter)="selectActive(match)">
<a href="#"
Expand All @@ -56,7 +101,8 @@ const bs3 = `
[ngOutletContext]="{item: match, index: i}">
</template>
</a>
</li>
</li>
</template>
</ul>
`;
let isBS4 = Ng2BootstrapConfig.theme === Ng2BootstrapTheme.BS4;
Expand All @@ -78,6 +124,8 @@ export class TypeaheadContainerComponent {
private _active:any;
private _matches:Array<any> = [];
private _field:string;
private _groupField:string;
private _groups:string[] = [];
private placement:string;

public constructor(element:ElementRef, options:TypeaheadOptions) {
Expand All @@ -89,6 +137,14 @@ export class TypeaheadContainerComponent {
return this._matches;
}

public get groups():string[] {
return this._groups;
}

public matchesByGroup(group:string):Array<any> {
return this.matches.filter((match:any) => TypeaheadUtils.getValueFromObject(match, this._groupField) === group);
}

public get itemTemplate():TemplateRef<any> {
return this.parent ? this.parent.typeaheadItemTemplate : undefined;
}
Expand All @@ -104,8 +160,15 @@ export class TypeaheadContainerComponent {
this._field = value;
}

public set groupField(value:string) {
this._groupField = value;
}

public set groups(value:string[]) {
this._groups = value;
}

public position(hostEl:ElementRef):void {
this.display = 'block';
this.top = '0px';
this.left = '0px';
let p = positionService
Expand Down Expand Up @@ -141,7 +204,7 @@ export class TypeaheadContainerComponent {

protected hightlight(item:any, query:any):string {
let itemStr:string = TypeaheadUtils.getValueFromObject(item, this._field);
let itemStrHelper:string = (this.parent.typeaheadLatinize
let itemStrHelper:string = (this.parent && this.parent.typeaheadLatinize
? TypeaheadUtils.latinize(itemStr)
: itemStr).toLowerCase();
let startIdx:number;
Expand Down Expand Up @@ -177,6 +240,10 @@ export class TypeaheadContainerComponent {
return this._active === value;
}

public hasGroups():boolean {
return this._groups && this._groups.length > 0;
}

private selectMatch(value:any, e:Event = void 0):boolean {
if (e) {
e.stopPropagation();
Expand Down
Loading

0 comments on commit fdddbde

Please sign in to comment.