Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scroll for typeahead #1182

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
core.autocrlf=false
24 changes: 17 additions & 7 deletions components/position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,16 @@ export class PositionService {
return targetElPos;
}

private get window():Window {
return window;
}

private get document():Document {
return window.document;
/**
* returns true if element is the currently focused element
* element
* @param nativeEl
*/
public isFocused(nativeEl: HTMLElement): boolean {
return this.document.activeElement === nativeEl;
}

private getStyle(nativeEl:HTMLElement, cssProp:string):string {
public getStyle(nativeEl:HTMLElement, cssProp:string):string {
// IE
if ((nativeEl as any).currentStyle) {
return (nativeEl as any).currentStyle[cssProp];
Expand All @@ -126,6 +127,14 @@ export class PositionService {
return (nativeEl.style as KeyAttribute)[cssProp];
}

private get window():Window {
return window;
}

private get document():Document {
return window.document;
}

/**
* Checks if a given element is statically positioned
* @param nativeEl - raw DOM element
Expand All @@ -147,6 +156,7 @@ export class PositionService {
}
return offsetParent || this.document;
};

}

export const positionService:PositionService = new PositionService();
5 changes: 5 additions & 0 deletions components/typeahead/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export class TypeaheadDirective implements OnInit {
@Input() public typeaheadWordDelimiters:string = ' ';
@Input() public typeaheadPhraseDelimiters:string = '\'"';
@Input() public typeaheadItemTemplate:TemplateRef<any>;
@Input() public typeaheadScrollable:boolean = false;
@Input() public typeaheadOptionsInScrollableView:number = 5;

// not yet implemented
@Input() private typeaheadAppendToBody:boolean;
Expand Down Expand Up @@ -55,13 +57,16 @@ export class TypeaheadDirective implements OnInit {
- `typeaheadWordDelimiters` (`?string=" "`) - should be used only in case `typeaheadSingleWords` attribute is `true`. Sets the word delimiter to break words. Defaults to space.
- `typeaheadPhraseDelimiters` (`?string="'\""`) - should be used only in case `typeaheadSingleWords` attribute is `true`. Sets the word delimiter to match exact phrase. Defaults to simple and double quotes.
- `typeaheadItemTemplate` (`?TemplateRef`) - used to specify a custom item template. Template variables exposed are called `item` and `index`;
- `typeaheadScrollable` (`?boolean`) - used to set the dropdown container to scrollable and scroll on key down/up
- `typeaheadOptionsInScrollableView` (`?number`) - used to set how many items to show in the scrollable dropdown list (only used if `typeaheadScrollable` is `true`)
- `typeaheadAppendToBody` (*not implemented*) (`?boolean=false`) - if `true` the typeahead popup will be appended to $body instead of the parent element
- `typeaheadEditable` (*not implemented*) (`?boolean=true`) - if `false` restrict model values to the ones selected from the popup only will be provided
- `typeaheadFocusFirst` (*not implemented*) (`?boolean=true`) - if `false` the first match automatically will not be focused as you type
- `typeaheadInputFormatter` (*not implemented*) (`?any`) - format the ng-model result after selection
- `typeaheadSelectOnExact` (*not implemented*) (`?boolean=false`) - if `true` automatically select an item when there is one option that exactly matches the user input
- `typeaheadSelectOnBlur` (*not implemented*) (`?boolean=false`) - if `true` select the currently highlighted match on blur
- `typeaheadFocusOnSelect` (*not implemented*) (`?boolean=true`) - if `false` don't focus the input element the typeahead directive is associated with on selection


### Typeahead events

Expand Down
157 changes: 141 additions & 16 deletions components/typeahead/typeahead-container.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ import { TypeaheadOptions } from './typeahead-options.class';
import { TypeaheadMatch } from './typeahead-match.class';

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

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

component = fixture.componentInstance;
fixture.detectChanges();
component.ngAfterViewInit();
containingElement = asNativeElements(fixture.debugElement.queryAll(By.css('.dropdown-menu')));
});

it('should be defined', () => {
Expand All @@ -35,12 +41,11 @@ describe('Component: TypeaheadContainer', () => {
});

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

let dropDown: HTMLElement;
beforeEach(() => {
component.position(fixture.elementRef);
fixture.detectChanges();

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

Expand All @@ -59,19 +64,23 @@ describe('Component: TypeaheadContainer', () => {
it('should have left style set', () => {
expect(dropDown.style.left).toBe('8px');
});

it('should not be scrollable', () => {
expect(getComputedStyle(dropDown).getPropertyValue('overflow-y')).toBe('auto');
});
});

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

beforeEach(() => {
component.query = 'fo';
component.matches = [
new TypeaheadMatch({id: 0, name: 'foo'}, 'foo'),
new TypeaheadMatch({id: 1, name: 'food'}, 'food')
new TypeaheadMatch({ id: 0, name: 'foo' }, 'foo'),
new TypeaheadMatch({ id: 1, name: 'food' }, 'food')
];
fixture.detectChanges();

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

Expand All @@ -80,6 +89,10 @@ describe('Component: TypeaheadContainer', () => {
expect(matches.length).toBe(2);
});

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

it('should highlight query for match', () => {
expect(matches[1].children[0].innerHTML).toBe('<strong>fo</strong>od');
});
Expand Down Expand Up @@ -125,18 +138,19 @@ describe('Component: TypeaheadContainer', () => {
});

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

beforeEach(() => {
component.query = 'a';
component.matches = [
new TypeaheadMatch('fruits', 'fruits', true),
new TypeaheadMatch({id: 0, name: 'banana', category: 'fruits'}, 'banana'),
new TypeaheadMatch({id: 0, name: 'apple', category: 'fruits'}, 'apple')
new TypeaheadMatch({ id: 0, name: 'banana', category: 'fruits' }, 'banana'),
new TypeaheadMatch({ id: 0, name: 'apple', category: 'fruits' }, 'apple')
];

fixture.detectChanges();
component.ngAfterViewInit();
headerMatch = fixture.debugElement.query(By.css('.dropdown-header')).nativeElement;
itemMatches = asNativeElements(fixture.debugElement.queryAll(By.css('.dropdown-menu li:not(.dropdown-header)')));
});
Expand Down Expand Up @@ -206,4 +220,115 @@ describe('Component: TypeaheadContainer', () => {
});
});

describe('scrollable matches', () => {
let itemMatches: HTMLLIElement[];
let headerMatch: HTMLLIElement;
let containingElementScrollable: HTMLElement;

beforeEach(() => {

options.scrollable = true;
options.optionsInScrollableView = 3;
fixture = testModule.createComponent(TypeaheadContainerComponent);
fixture.detectChanges();
component = fixture.componentInstance;

component.query = 'a';
component.matches = [
new TypeaheadMatch('fruits', 'fruits', true),
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();
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 render 9 item matches', () => {
expect(itemMatches.length).toBe(9);
});

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

it('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].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[2])).toBeTruthy();
});
it('should select the next item match and scroll', () => {
component.nextActiveMatch();
component.nextActiveMatch();
expect(component.isActive(component.matches[3])).toBeTruthy();
expect(containingElementScrollable[0].scrollTop).toBe(itemMatches[2].offsetTop - containingElementScrollable[0].offsetHeight + itemMatches[2].offsetHeight);

});
it('should select the last item match and scroll', () => {
for (let i = 0; i < 8; i++) {
component.nextActiveMatch();
}
expect(component.isActive(component.matches[11])).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[1])).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[11])).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[3])).toBeTruthy();
});
});

});

});
Loading