Skip to content
This repository was archived by the owner on May 29, 2019. It is now read-only.

feat(typeahead): add typeahead directive #169

Merged
merged 1 commit into from
Mar 1, 2013
Merged
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
4 changes: 4 additions & 0 deletions src/typeahead/docs/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class='container-fluid' ng-controller="TypeaheadCtrl">
<pre>Model: {{selected| json}}</pre>
<input type="text" ng-model="selected" typeahead="state for state in states | filter:$viewValue">
</div>
5 changes: 5 additions & 0 deletions src/typeahead/docs/demo.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/typeahead/docs/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Typeahead is a AngularJS version of [Twitter Bootstrap typeahead plugin](http://twitter.github.com/bootstrap/javascript.html#typeahead)

This directive can be used to quickly create elegant typeheads with any form text input.

It is very well integrated into the AngularJS as:

* it uses the same, flexible syntax as the `select` directive (http://docs.angularjs.org/api/ng.directive:select)
* works with promises and it means that you can retrieve matches using the `$http` service with minimal effort
309 changes: 309 additions & 0 deletions src/typeahead/test/typeahead.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
describe('typeahead tests', function () {

beforeEach(module('ui.bootstrap.typeahead'));
beforeEach(module('template/typeahead/typeahead.html'));

describe('syntax parser', function () {

var typeaheadParser, scope, filterFilter;
beforeEach(inject(function (_$rootScope_, _filterFilter_, _typeaheadParser_) {
typeaheadParser = _typeaheadParser_;
scope = _$rootScope_;
filterFilter = _filterFilter_;
}));

it('should parse the simplest array-based syntax', function () {
scope.states = ['Alabama', 'California', 'Delaware'];
var result = typeaheadParser.parse('state for state in states | filter:$viewValue');

var itemName = result.itemName;
var locals = {$viewValue:'al'};
expect(result.source(scope, locals)).toEqual(['Alabama', 'California']);

locals[itemName] = 'Alabama';
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
});

it('should parse the simplest function-based syntax', function () {
scope.getStates = function ($viewValue) {
return filterFilter(['Alabama', 'California', 'Delaware'], $viewValue);
};
var result = typeaheadParser.parse('state for state in getStates($viewValue)');

var itemName = result.itemName;
var locals = {$viewValue:'al'};
expect(result.source(scope, locals)).toEqual(['Alabama', 'California']);

locals[itemName] = 'Alabama';
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
});

it('should allow to specify custom model mapping that is used as a label as well', function () {

scope.states = [
{code:'AL', name:'Alabama'},
{code:'CA', name:'California'},
{code:'DE', name:'Delaware'}
];
var result = typeaheadParser.parse("state.name for state in states | filter:$viewValue | orderBy:'name':true");

var itemName = result.itemName;
expect(itemName).toEqual('state');
expect(result.source(scope, {$viewValue:'al'})).toEqual([
{code:'CA', name:'California'},
{code:'AL', name:'Alabama'}
]);

var locals = {$viewValue:'al'};
locals[itemName] = {code:'AL', name:'Alabama'};
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
});

it('should allow to specify custom view and model mappers', function () {

scope.states = [
{code:'AL', name:'Alabama'},
{code:'CA', name:'California'},
{code:'DE', name:'Delaware'}
];
var result = typeaheadParser.parse("state.code as state.name + ' ('+state.code+')' for state in states | filter:$viewValue | orderBy:'name':true");

var itemName = result.itemName;
expect(result.source(scope, {$viewValue:'al'})).toEqual([
{code:'CA', name:'California'},
{code:'AL', name:'Alabama'}
]);

var locals = {$viewValue:'al'};
locals[itemName] = {code:'AL', name:'Alabama'};
expect(result.viewMapper(scope, locals)).toEqual('Alabama (AL)');
expect(result.modelMapper(scope, locals)).toEqual('AL');
});
});

describe('typeaheadPopup - result rendering', function () {

var scope, $rootScope, $compile;
beforeEach(inject(function (_$rootScope_, _$compile_) {
$rootScope = _$rootScope_;
scope = $rootScope.$new();
$compile = _$compile_;
}));

it('should render initial results', function () {

scope.matches = ['foo', 'bar', 'baz'];
scope.active = 1;

var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
$rootScope.$digest();

var liElems = el.find('li');
expect(liElems.length).toEqual(3);
expect(liElems.eq(0)).not.toHaveClass('active');
expect(liElems.eq(1)).toHaveClass('active');
expect(liElems.eq(2)).not.toHaveClass('active');
});

it('should change active item on mouseenter', function () {

scope.matches = ['foo', 'bar', 'baz'];
scope.active = 1;

var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
$rootScope.$digest();

var liElems = el.find('li');
expect(liElems.eq(1)).toHaveClass('active');
expect(liElems.eq(2)).not.toHaveClass('active');

liElems.eq(2).trigger('mouseenter');

expect(liElems.eq(1)).not.toHaveClass('active');
expect(liElems.eq(2)).toHaveClass('active');
});

it('should select an item on mouse click', function () {

scope.matches = ['foo', 'bar', 'baz'];
scope.active = 1;
$rootScope.select = angular.noop;
spyOn($rootScope, 'select');

var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
$rootScope.$digest();

var liElems = el.find('li');
liElems.eq(2).find('a').trigger('click');
expect($rootScope.select).toHaveBeenCalledWith(2);
});
});

describe('typeahead', function () {

var $scope, $compile;
var changeInputValueTo;

beforeEach(inject(function (_$rootScope_, _$compile_, $sniffer) {
$scope = _$rootScope_;
$scope.source = ['foo', 'bar', 'baz'];
$compile = _$compile_;

changeInputValueTo = function (element, value) {
var inputEl = findInput(element);
inputEl.val(value);
inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change');
$scope.$digest();
};
}));

//utility functions
var prepareInputEl = function(inputTpl) {
var el = $compile(angular.element(inputTpl))($scope);
$scope.$digest();
return el;
};

var findInput = function(element) {
return element.find('input');
};

var findDropDown = function(element) {
return element.find('div.dropdown');
};

var findMatches = function(element) {
return findDropDown(element).find('li');
};

var triggerKeyDown = function(element, keyCode) {
var inputEl = findInput(element);
var e = $.Event("keydown");
e.which = keyCode;
inputEl.trigger(e);
};

//custom matchers
beforeEach(function () {
this.addMatchers({
toBeClosed: function() {
var typeaheadEl = findDropDown(this.actual);
this.message = function() {
return "Expected '" + angular.mock.dump(this.actual) + "' to be closed.";
};
return !typeaheadEl.hasClass('open') && findMatches(this.actual).length === 0;

}, toBeOpenWithActive: function(noOfMatches, activeIdx) {

var typeaheadEl = findDropDown(this.actual);
var liEls = findMatches(this.actual);

this.message = function() {
return "Expected '" + angular.mock.dump(this.actual) + "' to be opened.";
};
return typeaheadEl.hasClass('open') && liEls.length === noOfMatches && $(liEls[activeIdx]).hasClass('active');
}
});
});

//coarse grained, "integration" tests
describe('initial state and model changes', function () {

it('should be closed by default', function () {
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source'></div>");
expect(element).toBeClosed();
});

it('should not get open on model change', function () {
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source'></div>");
$scope.$apply(function(){
$scope.result = 'foo';
});
expect(element).toBeClosed();
});
});

describe('basic functionality', function () {

it('should open and close typeahead based on matches', function () {
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
changeInputValueTo(element, 'ba');
expect(element).toBeOpenWithActive(2, 0);
});

it('should not open typeahead if input value smaller than a defined threshold', function () {
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue' typeahead-min-length='2'></div>");
changeInputValueTo(element, 'b');
expect(element).toBeClosed();
});

it('should support custom model selecting function', function () {
$scope.updaterFn = function(selectedItem) {
return 'prefix' + selectedItem;
};
var element = prepareInputEl("<div><input ng-model='result' typeahead='updaterFn(item) as item for item in source | filter:$viewValue'></div>");
changeInputValueTo(element, 'f');
triggerKeyDown(element, 13);
expect($scope.result).toEqual('prefixfoo');
});

it('should support custom label rendering function', function () {
$scope.formatterFn = function(sourceItem) {
return 'prefix' + sourceItem;
};

var element = prepareInputEl("<div><input ng-model='result' typeahead='item as formatterFn(item) for item in source | filter:$viewValue'></div>");
changeInputValueTo(element, 'fo');
var matchHighlight = findMatches(element).find('a').html();
expect(matchHighlight).toEqual('prefix<strong>fo</strong>o');
});

});

describe('selecting a match', function () {

it('should select a match on enter', function () {

var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
var inputEl = findInput(element);

changeInputValueTo(element, 'b');
triggerKeyDown(element, 13);

expect($scope.result).toEqual('bar');
expect(inputEl.val()).toEqual('bar');
});

it('should select a match on tab', function () {

var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
var inputEl = findInput(element);

changeInputValueTo(element, 'b');
triggerKeyDown(element, 9);

expect($scope.result).toEqual('bar');
expect(inputEl.val()).toEqual('bar');
});

it('should select match on click', function () {

var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
var inputEl = findInput(element);

changeInputValueTo(element, 'b');
var match = $(findMatches(element)[1]).find('a')[0];

$(match).click();
$scope.$digest();

expect($scope.result).toEqual('baz');
expect(inputEl.val()).toEqual('baz');
});
});

});
});
Loading