Skip to content

Commit

Permalink
feat(typeahead): add scroll support (#2821)
Browse files Browse the repository at this point in the history
  • Loading branch information
IlyaSurmay authored and valorkin committed Oct 20, 2017
1 parent 64d13e7 commit 033f6e3
Show file tree
Hide file tree
Showing 10 changed files with 383 additions and 55 deletions.
8 changes: 7 additions & 1 deletion demo/src/app/components/+typeahead/demos/index.ts
Expand Up @@ -5,6 +5,7 @@ import { DemoTypeaheadAsyncComponent } from './async/async';
import { DemoTypeaheadFormsComponent } from './in-form/in-form';
import { DemoTypeaheadGroupingComponent } from './grouping/grouping';
import { DemoTypeaheadDropupComponent } from './dropup/dropup';
import { DemoTypeaheadScrollableComponent } from './scrollable/scrollable';

export const DEMO_COMPONENTS = [
DemoTypeaheadStaticComponent,
Expand All @@ -13,7 +14,8 @@ export const DEMO_COMPONENTS = [
DemoTypeaheadAsyncComponent,
DemoTypeaheadFormsComponent,
DemoTypeaheadGroupingComponent,
DemoTypeaheadDropupComponent
DemoTypeaheadDropupComponent,
DemoTypeaheadScrollableComponent
];

export const DEMOS = {
Expand Down Expand Up @@ -44,5 +46,9 @@ export const DEMOS = {
dropup: {
component: require('!!raw-loader?lang=typescript!./dropup/dropup.ts'),
html: require('!!raw-loader?lang=markup!./dropup/dropup.html')
},
scrollable: {
component: require('!!raw-loader?lang=typescript!./scrollable/scrollable.ts'),
html: require('!!raw-loader?lang=markup!./scrollable/scrollable.html')
}
};
@@ -0,0 +1,6 @@
<pre class="card card-block card-header">Model: {{selected | json}}</pre>
<input [(ngModel)]="selected"
[typeahead]="states"
[typeaheadScrollable]="true"
[typeaheadOptionsInScrollableView]="5"
class="form-control">
61 changes: 61 additions & 0 deletions demo/src/app/components/+typeahead/demos/scrollable/scrollable.ts
@@ -0,0 +1,61 @@
import { Component } from '@angular/core';

@Component({
selector: 'demo-typeahead-scrollable',
templateUrl: './scrollable.html'
})
export class DemoTypeaheadScrollableComponent {
selected: string;
states: string[] = [
'Alabama',
'Alaska',
'Arizona',
'Arkansas',
'California',
'Colorado',
'Connecticut',
'Delaware',
'Florida',
'Georgia',
'Hawaii',
'Idaho',
'Illinois',
'Indiana',
'Iowa',
'Kansas',
'Kentucky',
'Louisiana',
'Maine',
'Maryland',
'Massachusetts',
'Michigan',
'Minnesota',
'Mississippi',
'Missouri',
'Montana',
'Nebraska',
'Nevada',
'New Hampshire',
'New Jersey',
'New Mexico',
'New York',
'North Dakota',
'North Carolina',
'Ohio',
'Oklahoma',
'Oregon',
'Pennsylvania',
'Rhode Island',
'South Carolina',
'South Dakota',
'Tennessee',
'Texas',
'Utah',
'Vermont',
'Virginia',
'Washington',
'West Virginia',
'Wisconsin',
'Wyoming'
];
}
Expand Up @@ -11,6 +11,7 @@ <h2>Contents</h2>
<li><a routerLink="." fragment="forms">Reactive forms</a></li>
<li><a routerLink="." fragment="grouping-results">Grouping results</a></li>
<li><a routerLink="." fragment="dropup">Dropup</a></li>
<li><a routerLink="." fragment="scrollable">Scrollable</a></li>
</ul>
</li>
<li><a routerLink="." fragment="api-reference">API Reference</a>
Expand Down Expand Up @@ -68,6 +69,12 @@ <h3 routerLink="." fragment="dropup" id="dropup">Dropup</h3>
<demo-typeahead-dropup></demo-typeahead-dropup>
</ng-sample-box>

<!-- Scrollable -->
<h3 routerLink="." fragment="scrollable" id="scrollable">Scrollable</h3>
<ng-sample-box [ts]="demos.scrollable.component" [html]="demos.scrollable.html">
<demo-typeahead-scrollable></demo-typeahead-scrollable>
</ng-sample-box>

<h2 routerLink="." fragment="api-reference" id="api-reference">API Reference</h2>
<ng-api-doc id="typeahead-directive" directive="TypeaheadDirective"></ng-api-doc>
</demo-section>
12 changes: 12 additions & 0 deletions demo/src/ng-api-doc.ts
Expand Up @@ -2969,6 +2969,12 @@ export const ngdoc: any = {
"type": "string",
"description": "<p>when options source is an array of objects, the name of field\nthat contains the options value, we use array item as option in case\nof this field is missing. Supports nested properties and methods.</p>\n"
},
{
"name": "typeaheadOptionsInScrollableView",
"defaultValue": "5",
"type": "number",
"description": "<p>specifies number of options to show in scroll view </p>\n"
},
{
"name": "typeaheadOptionsLimit",
"type": "number",
Expand All @@ -2980,6 +2986,12 @@ export const ngdoc: any = {
"type": "string",
"description": "<p>should be used only in case typeaheadSingleWords attribute is true.\nSets the word delimiter to match exact phrase.\nDefaults to simple and double quotes.</p>\n"
},
{
"name": "typeaheadScrollable",
"defaultValue": "false",
"type": "boolean",
"description": "<p>specifies if typeahead is scrollable </p>\n"
},
{
"name": "typeaheadSingleWords",
"defaultValue": "true",
Expand Down
163 changes: 148 additions & 15 deletions src/spec/typeahead-container.component.spec.ts
@@ -1,32 +1,30 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { asNativeElements } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TypeaheadContainerComponent } from '../typeahead/typeahead-container.component';
import { TypeaheadOptions } from '../typeahead/typeahead-options.class';
import { TypeaheadMatch } from '../typeahead/typeahead-match.class';
import { TypeaheadDirective } from '../typeahead/typeahead.directive';

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

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

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

it('should be defined', () => {
expect(component).toBeTruthy();
Expand Down Expand Up @@ -219,4 +217,139 @@ describe('Component: TypeaheadContainer', () => {
expect(component.isFocused).toBeFalsy();
});
});
describe('scrollable matches', () => {
let itemMatches: HTMLLIElement[];
let headerMatch: HTMLLIElement;
let containingElementScrollable: HTMLElement[];

beforeEach(fakeAsync(() => {
fixture = testModule.createComponent(TypeaheadContainerComponent);
component = fixture.componentInstance;
component.parent = { typeaheadOptionsInScrollableView: 3, typeaheadScrollable: true } as TypeaheadDirective;
fixture.detectChanges();
tick(1);
component.query = 'a';
component.matches = [
new TypeaheadMatch({ id: 0, name: 'banana', category: 'fruits' }, 'banana'),
new TypeaheadMatch({ id: 1, name: 'apple', category: 'fruits' }, 'apple'),
new TypeaheadMatch({ id: 2, name: 'orange', category: 'fruits' }, 'orange'),
new TypeaheadMatch({ id: 3, name: 'pear', category: 'fruits' }, 'pear'),
new TypeaheadMatch({ id: 4, name: 'pineapple', category: 'fruits' }, 'pineapple'),
new TypeaheadMatch('berries', 'berries', true),
new TypeaheadMatch({ id: 5, name: 'strawberry', category: 'berries' }, 'strawberry'),
new TypeaheadMatch({ id: 6, name: 'raspberry', category: 'berries' }, 'raspberry'),
new TypeaheadMatch('vegatables', 'vegatables', true),
new TypeaheadMatch({ id: 7, name: 'tomato', category: 'vegatables' }, 'tomato'),
new TypeaheadMatch({ id: 8, name: 'cucumber', category: 'vegatables' }, 'cucumber')
];

fixture.detectChanges();
tick(1);
// component.ngAfterViewInit();
let headers = fixture.debugElement.queryAll(By.css('.dropdown-header'));
if (headers) {
headerMatch = asNativeElements(headers);
}
itemMatches = asNativeElements(fixture.debugElement.queryAll(By.css('.dropdown-menu li:not(.dropdown-header)')));
containingElementScrollable = asNativeElements(fixture.debugElement.queryAll(By.css('.dropdown-menu')));
}));

describe('rendering', () => {
it('should render scrollable element', () => {
expect(containingElementScrollable[0]).toBeDefined();
});

it('should not throw exception when scrollPrevious is without li elements', () => {
(component as any).liElements = undefined;
(component as any).scrollPrevious(1);
expect(component.element.nativeElement.scrollTop).toBe(0);
});

it('should not throw exception when scrollPrevious is scrolling outside of index ', () => {
(component as any).scrollPrevious(100);
expect(component.element.nativeElement.scrollTop).toBe(0);

});

it('should not throw exception when scrollNext is without li elements', () => {
(component as any).liElements = undefined;

(component as any).scrollNext(1);
expect(component.element.nativeElement.scrollTop).toBe(0);

});

it('should not throw exception when scrollNext is scrolling outside of index', () => {
(component as any).scrollNext(100);
expect(component.element.nativeElement.scrollTop).toBe(0);
});

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

it('should show scrollbars', () => {
expect(getComputedStyle(containingElementScrollable[0]).getPropertyValue('overflow-y')).toBe('scroll');
});

xit('should show correct height on scrollable element', () => {
expect(getComputedStyle(containingElementScrollable[0]).getPropertyValue('height')).toBe('60px');
});

it('should highlight query for item match', () => {
expect(itemMatches[1].children[0].children[0].innerHTML).toBe('<strong>a</strong>pple');
});

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

describe('nextActiveMatch', () => {
it('should select the next item match', () => {
component.nextActiveMatch();
expect(component.isActive(component.matches[1])).toBeTruthy();
});
it('should select the next item match and scroll', fakeAsync(() => {
component.nextActiveMatch();
component.nextActiveMatch();
fixture.detectChanges();
tick(1);
expect(component.isActive(component.matches[2])).toBeTruthy();
expect(containingElementScrollable[0].scrollTop).toBe(0);
}));
it('should select the last item match and scroll', () => {
for (let i = 0; i < 8; i++) {
component.nextActiveMatch();
}
expect(component.isActive(component.matches[10])).toBeTruthy();
});

it('should select the first item match and scroll to top', () => {
for (let i = 0; i < 9; i++) {
component.nextActiveMatch();
}
expect(component.isActive(component.matches[0])).toBeTruthy();
expect(containingElementScrollable[0].scrollTop).toBe(0);
});
});

describe('prevActiveMatch', () => {
it('should select the last item and scroll to bottom', () => {
component.prevActiveMatch();
expect(component.isActive(component.matches[10])).toBeTruthy();
expect(containingElementScrollable[0].scrollTop <= containingElementScrollable[0].scrollHeight).toBeTruthy();
});

it('should select the prev item match', () => {
component.nextActiveMatch();
component.nextActiveMatch();
component.nextActiveMatch();
component.prevActiveMatch();
expect(component.isActive(component.matches[2])).toBeTruthy();
});
});

});

});
10 changes: 5 additions & 5 deletions src/spec/typeahead.directive.spec.ts
Expand Up @@ -107,7 +107,7 @@ describe('Directive: Typeahead', () => {
beforeEach(
fakeAsync(() => {
inputElement.value = 'Ala';
fireEvent(inputElement, 'keyup');
fireEvent(inputElement, 'input');

fixture.detectChanges();
tick(100);
Expand Down Expand Up @@ -154,7 +154,7 @@ describe('Directive: Typeahead', () => {
'should result in 0 matches, when input does not match',
fakeAsync(() => {
inputElement.value = 'foo';
fireEvent(inputElement, 'keyup');
fireEvent(inputElement, 'input');

fixture.detectChanges();
tick(100);
Expand All @@ -168,7 +168,7 @@ describe('Directive: Typeahead', () => {
fakeAsync(() => {
component.states.push({id: 3, name: null, region: 'West'});
inputElement.value = 'Ala';
fireEvent(inputElement, 'keyup');
fireEvent(inputElement, 'input');
fixture.detectChanges();
tick(100);

Expand All @@ -181,7 +181,7 @@ describe('Directive: Typeahead', () => {
beforeEach(
fakeAsync(() => {
inputElement.value = 'Ala';
fireEvent(inputElement, 'keyup');
fireEvent(inputElement, 'input');
directive.typeaheadGroupField = 'region';

fixture.detectChanges();
Expand Down Expand Up @@ -244,7 +244,7 @@ describe('Directive: Typeahead', () => {
beforeEach(
fakeAsync(() => {
inputElement.value = 'Alab';
fireEvent(inputElement, 'keyup');
fireEvent(inputElement, 'input');

fixture.detectChanges();
tick(100);
Expand Down

0 comments on commit 033f6e3

Please sign in to comment.