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

Commit 6a97da2

Browse files
committedMar 1, 2013
feat(typeahead): add typeahead directive
Closes #114
1 parent 3b677ee commit 6a97da2

File tree

6 files changed

+538
-0
lines changed

6 files changed

+538
-0
lines changed
 

‎src/typeahead/docs/demo.html

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<div class='container-fluid' ng-controller="TypeaheadCtrl">
2+
<pre>Model: {{selected| json}}</pre>
3+
<input type="text" ng-model="selected" typeahead="state for state in states | filter:$viewValue">
4+
</div>

‎src/typeahead/docs/demo.js

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/typeahead/docs/readme.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Typeahead is a AngularJS version of [Twitter Bootstrap typeahead plugin](http://twitter.github.com/bootstrap/javascript.html#typeahead)
2+
3+
This directive can be used to quickly create elegant typeheads with any form text input.
4+
5+
It is very well integrated into the AngularJS as:
6+
7+
* it uses the same, flexible syntax as the `select` directive (http://docs.angularjs.org/api/ng.directive:select)
8+
* works with promises and it means that you can retrieve matches using the `$http` service with minimal effort

‎src/typeahead/test/typeahead.spec.js

+309
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
describe('typeahead tests', function () {
2+
3+
beforeEach(module('ui.bootstrap.typeahead'));
4+
beforeEach(module('template/typeahead/typeahead.html'));
5+
6+
describe('syntax parser', function () {
7+
8+
var typeaheadParser, scope, filterFilter;
9+
beforeEach(inject(function (_$rootScope_, _filterFilter_, _typeaheadParser_) {
10+
typeaheadParser = _typeaheadParser_;
11+
scope = _$rootScope_;
12+
filterFilter = _filterFilter_;
13+
}));
14+
15+
it('should parse the simplest array-based syntax', function () {
16+
scope.states = ['Alabama', 'California', 'Delaware'];
17+
var result = typeaheadParser.parse('state for state in states | filter:$viewValue');
18+
19+
var itemName = result.itemName;
20+
var locals = {$viewValue:'al'};
21+
expect(result.source(scope, locals)).toEqual(['Alabama', 'California']);
22+
23+
locals[itemName] = 'Alabama';
24+
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
25+
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
26+
});
27+
28+
it('should parse the simplest function-based syntax', function () {
29+
scope.getStates = function ($viewValue) {
30+
return filterFilter(['Alabama', 'California', 'Delaware'], $viewValue);
31+
};
32+
var result = typeaheadParser.parse('state for state in getStates($viewValue)');
33+
34+
var itemName = result.itemName;
35+
var locals = {$viewValue:'al'};
36+
expect(result.source(scope, locals)).toEqual(['Alabama', 'California']);
37+
38+
locals[itemName] = 'Alabama';
39+
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
40+
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
41+
});
42+
43+
it('should allow to specify custom model mapping that is used as a label as well', function () {
44+
45+
scope.states = [
46+
{code:'AL', name:'Alabama'},
47+
{code:'CA', name:'California'},
48+
{code:'DE', name:'Delaware'}
49+
];
50+
var result = typeaheadParser.parse("state.name for state in states | filter:$viewValue | orderBy:'name':true");
51+
52+
var itemName = result.itemName;
53+
expect(itemName).toEqual('state');
54+
expect(result.source(scope, {$viewValue:'al'})).toEqual([
55+
{code:'CA', name:'California'},
56+
{code:'AL', name:'Alabama'}
57+
]);
58+
59+
var locals = {$viewValue:'al'};
60+
locals[itemName] = {code:'AL', name:'Alabama'};
61+
expect(result.viewMapper(scope, locals)).toEqual('Alabama');
62+
expect(result.modelMapper(scope, locals)).toEqual('Alabama');
63+
});
64+
65+
it('should allow to specify custom view and model mappers', function () {
66+
67+
scope.states = [
68+
{code:'AL', name:'Alabama'},
69+
{code:'CA', name:'California'},
70+
{code:'DE', name:'Delaware'}
71+
];
72+
var result = typeaheadParser.parse("state.code as state.name + ' ('+state.code+')' for state in states | filter:$viewValue | orderBy:'name':true");
73+
74+
var itemName = result.itemName;
75+
expect(result.source(scope, {$viewValue:'al'})).toEqual([
76+
{code:'CA', name:'California'},
77+
{code:'AL', name:'Alabama'}
78+
]);
79+
80+
var locals = {$viewValue:'al'};
81+
locals[itemName] = {code:'AL', name:'Alabama'};
82+
expect(result.viewMapper(scope, locals)).toEqual('Alabama (AL)');
83+
expect(result.modelMapper(scope, locals)).toEqual('AL');
84+
});
85+
});
86+
87+
describe('typeaheadPopup - result rendering', function () {
88+
89+
var scope, $rootScope, $compile;
90+
beforeEach(inject(function (_$rootScope_, _$compile_) {
91+
$rootScope = _$rootScope_;
92+
scope = $rootScope.$new();
93+
$compile = _$compile_;
94+
}));
95+
96+
it('should render initial results', function () {
97+
98+
scope.matches = ['foo', 'bar', 'baz'];
99+
scope.active = 1;
100+
101+
var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
102+
$rootScope.$digest();
103+
104+
var liElems = el.find('li');
105+
expect(liElems.length).toEqual(3);
106+
expect(liElems.eq(0)).not.toHaveClass('active');
107+
expect(liElems.eq(1)).toHaveClass('active');
108+
expect(liElems.eq(2)).not.toHaveClass('active');
109+
});
110+
111+
it('should change active item on mouseenter', function () {
112+
113+
scope.matches = ['foo', 'bar', 'baz'];
114+
scope.active = 1;
115+
116+
var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
117+
$rootScope.$digest();
118+
119+
var liElems = el.find('li');
120+
expect(liElems.eq(1)).toHaveClass('active');
121+
expect(liElems.eq(2)).not.toHaveClass('active');
122+
123+
liElems.eq(2).trigger('mouseenter');
124+
125+
expect(liElems.eq(1)).not.toHaveClass('active');
126+
expect(liElems.eq(2)).toHaveClass('active');
127+
});
128+
129+
it('should select an item on mouse click', function () {
130+
131+
scope.matches = ['foo', 'bar', 'baz'];
132+
scope.active = 1;
133+
$rootScope.select = angular.noop;
134+
spyOn($rootScope, 'select');
135+
136+
var el = $compile("<div><typeahead-popup matches='matches' active='active' select='select(activeIdx)'></typeahead-popup></div>")(scope);
137+
$rootScope.$digest();
138+
139+
var liElems = el.find('li');
140+
liElems.eq(2).find('a').trigger('click');
141+
expect($rootScope.select).toHaveBeenCalledWith(2);
142+
});
143+
});
144+
145+
describe('typeahead', function () {
146+
147+
var $scope, $compile;
148+
var changeInputValueTo;
149+
150+
beforeEach(inject(function (_$rootScope_, _$compile_, $sniffer) {
151+
$scope = _$rootScope_;
152+
$scope.source = ['foo', 'bar', 'baz'];
153+
$compile = _$compile_;
154+
155+
changeInputValueTo = function (element, value) {
156+
var inputEl = findInput(element);
157+
inputEl.val(value);
158+
inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change');
159+
$scope.$digest();
160+
};
161+
}));
162+
163+
//utility functions
164+
var prepareInputEl = function(inputTpl) {
165+
var el = $compile(angular.element(inputTpl))($scope);
166+
$scope.$digest();
167+
return el;
168+
};
169+
170+
var findInput = function(element) {
171+
return element.find('input');
172+
};
173+
174+
var findDropDown = function(element) {
175+
return element.find('div.dropdown');
176+
};
177+
178+
var findMatches = function(element) {
179+
return findDropDown(element).find('li');
180+
};
181+
182+
var triggerKeyDown = function(element, keyCode) {
183+
var inputEl = findInput(element);
184+
var e = $.Event("keydown");
185+
e.which = keyCode;
186+
inputEl.trigger(e);
187+
};
188+
189+
//custom matchers
190+
beforeEach(function () {
191+
this.addMatchers({
192+
toBeClosed: function() {
193+
var typeaheadEl = findDropDown(this.actual);
194+
this.message = function() {
195+
return "Expected '" + angular.mock.dump(this.actual) + "' to be closed.";
196+
};
197+
return !typeaheadEl.hasClass('open') && findMatches(this.actual).length === 0;
198+
199+
}, toBeOpenWithActive: function(noOfMatches, activeIdx) {
200+
201+
var typeaheadEl = findDropDown(this.actual);
202+
var liEls = findMatches(this.actual);
203+
204+
this.message = function() {
205+
return "Expected '" + angular.mock.dump(this.actual) + "' to be opened.";
206+
};
207+
return typeaheadEl.hasClass('open') && liEls.length === noOfMatches && $(liEls[activeIdx]).hasClass('active');
208+
}
209+
});
210+
});
211+
212+
//coarse grained, "integration" tests
213+
describe('initial state and model changes', function () {
214+
215+
it('should be closed by default', function () {
216+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source'></div>");
217+
expect(element).toBeClosed();
218+
});
219+
220+
it('should not get open on model change', function () {
221+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source'></div>");
222+
$scope.$apply(function(){
223+
$scope.result = 'foo';
224+
});
225+
expect(element).toBeClosed();
226+
});
227+
});
228+
229+
describe('basic functionality', function () {
230+
231+
it('should open and close typeahead based on matches', function () {
232+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
233+
changeInputValueTo(element, 'ba');
234+
expect(element).toBeOpenWithActive(2, 0);
235+
});
236+
237+
it('should not open typeahead if input value smaller than a defined threshold', function () {
238+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue' typeahead-min-length='2'></div>");
239+
changeInputValueTo(element, 'b');
240+
expect(element).toBeClosed();
241+
});
242+
243+
it('should support custom model selecting function', function () {
244+
$scope.updaterFn = function(selectedItem) {
245+
return 'prefix' + selectedItem;
246+
};
247+
var element = prepareInputEl("<div><input ng-model='result' typeahead='updaterFn(item) as item for item in source | filter:$viewValue'></div>");
248+
changeInputValueTo(element, 'f');
249+
triggerKeyDown(element, 13);
250+
expect($scope.result).toEqual('prefixfoo');
251+
});
252+
253+
it('should support custom label rendering function', function () {
254+
$scope.formatterFn = function(sourceItem) {
255+
return 'prefix' + sourceItem;
256+
};
257+
258+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item as formatterFn(item) for item in source | filter:$viewValue'></div>");
259+
changeInputValueTo(element, 'fo');
260+
var matchHighlight = findMatches(element).find('a').html();
261+
expect(matchHighlight).toEqual('prefix<strong>fo</strong>o');
262+
});
263+
264+
});
265+
266+
describe('selecting a match', function () {
267+
268+
it('should select a match on enter', function () {
269+
270+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
271+
var inputEl = findInput(element);
272+
273+
changeInputValueTo(element, 'b');
274+
triggerKeyDown(element, 13);
275+
276+
expect($scope.result).toEqual('bar');
277+
expect(inputEl.val()).toEqual('bar');
278+
});
279+
280+
it('should select a match on tab', function () {
281+
282+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
283+
var inputEl = findInput(element);
284+
285+
changeInputValueTo(element, 'b');
286+
triggerKeyDown(element, 9);
287+
288+
expect($scope.result).toEqual('bar');
289+
expect(inputEl.val()).toEqual('bar');
290+
});
291+
292+
it('should select match on click', function () {
293+
294+
var element = prepareInputEl("<div><input ng-model='result' typeahead='item for item in source | filter:$viewValue'></div>");
295+
var inputEl = findInput(element);
296+
297+
changeInputValueTo(element, 'b');
298+
var match = $(findMatches(element)[1]).find('a')[0];
299+
300+
$(match).click();
301+
$scope.$digest();
302+
303+
expect($scope.result).toEqual('baz');
304+
expect(inputEl.val()).toEqual('baz');
305+
});
306+
});
307+
308+
});
309+
});

‎src/typeahead/typeahead.js

+205
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
angular.module('ui.bootstrap.typeahead', [])
2+
3+
/**
4+
* A helper service that can parse typeahead's syntax (string provided by users)
5+
* Extracted to a separate service for ease of unit testing
6+
*/
7+
.factory('typeaheadParser', ['$parse', function ($parse) {
8+
9+
// 00000111000000000000022200000000000000003333333333333330000000000044000
10+
var TYPEAHEAD_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;
11+
12+
return {
13+
parse:function (input) {
14+
15+
var match = input.match(TYPEAHEAD_REGEXP), modelMapper, viewMapper, source;
16+
if (!match) {
17+
throw new Error(
18+
"Expected typeahead specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" +
19+
" but got '" + input + "'.");
20+
}
21+
22+
return {
23+
itemName:match[3],
24+
source:$parse(match[4]),
25+
viewMapper:$parse(match[2] || match[1]),
26+
modelMapper:$parse(match[1])
27+
};
28+
}
29+
};
30+
}])
31+
32+
//options - min length
33+
.directive('typeahead', ['$compile', '$q', 'typeaheadParser', function ($compile, $q, typeaheadParser) {
34+
35+
var HOT_KEYS = [9, 13, 27, 38, 40];
36+
37+
return {
38+
require:'ngModel',
39+
link:function (originalScope, element, attrs, modelCtrl) {
40+
41+
var selected = modelCtrl.$modelValue;
42+
43+
//minimal no of characters that needs to be entered before typeahead kicks-in
44+
var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1;
45+
46+
//expressions used by typeahead
47+
var parserResult = typeaheadParser.parse(attrs.typeahead);
48+
49+
//create a child scope for the typeahead directive so we are not polluting original scope
50+
//with typeahead-specific data (matches, query etc.)
51+
var scope = originalScope.$new();
52+
originalScope.$on('$destroy', function(){
53+
scope.$destroy();
54+
});
55+
56+
var resetMatches = function() {
57+
scope.matches = [];
58+
scope.activeIdx = -1;
59+
};
60+
61+
var getMatchesAsync = function(inputValue) {
62+
63+
var locals = {$viewValue: inputValue};
64+
$q.when(parserResult.source(scope, locals)).then(function(matches) {
65+
66+
//it might happen that several async queries were in progress if a user were typing fast
67+
//but we are interested only in responses that correspond to the current view value
68+
if (inputValue === modelCtrl.$viewValue) {
69+
if (matches.length > 0) {
70+
71+
scope.activeIdx = 0;
72+
scope.matches.length = 0;
73+
74+
//transform labels
75+
for(var i=0; i<matches.length; i++) {
76+
locals[parserResult.itemName] = matches[i];
77+
scope.matches.push({
78+
label: parserResult.viewMapper(scope, locals),
79+
model: matches[i]
80+
});
81+
}
82+
83+
scope.query = inputValue;
84+
85+
} else {
86+
resetMatches();
87+
}
88+
}
89+
}, resetMatches);
90+
};
91+
92+
resetMatches();
93+
94+
//we need to propagate user's query so we can higlight matches
95+
scope.query = undefined;
96+
97+
//plug into $parsers pipeline to open a typeahead on view changes initiated from DOM
98+
//$parsers kick-in on all the changes coming from the vview as well as manually triggered by $setViewValue
99+
modelCtrl.$parsers.push(function (inputValue) {
100+
101+
resetMatches();
102+
if (selected) {
103+
selected = undefined;
104+
return inputValue;
105+
} else {
106+
if (inputValue && inputValue.length >= minSearch) {
107+
getMatchesAsync(inputValue);
108+
}
109+
}
110+
111+
return undefined;
112+
});
113+
114+
modelCtrl.$render = function() {
115+
var locals = {};
116+
if (modelCtrl.$viewValue) {
117+
locals[parserResult.itemName] = modelCtrl.$viewValue;
118+
element.val(parserResult.viewMapper(scope, locals));
119+
}
120+
};
121+
122+
scope.select = function (activeIdx) {
123+
//called from within the $digest() cycle
124+
var locals = {};
125+
locals[parserResult.itemName] = scope.matches[activeIdx].model;
126+
127+
selected = parserResult.modelMapper(scope, locals);
128+
modelCtrl.$setViewValue(selected);
129+
modelCtrl.$render();
130+
};
131+
132+
//bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(9)
133+
element.bind('keydown', function (evt) {
134+
135+
//typeahead is open and an "interesting" key was pressed
136+
if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) {
137+
return;
138+
}
139+
140+
evt.preventDefault();
141+
142+
if (evt.which === 40) {
143+
scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
144+
scope.$digest();
145+
146+
} else if (evt.which === 38) {
147+
scope.activeIdx = (scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1;
148+
scope.$digest();
149+
150+
} else if (evt.which === 13 || evt.which === 9) {
151+
scope.$apply(function () {
152+
scope.select(scope.activeIdx);
153+
});
154+
155+
} else if (evt.which === 27) {
156+
scope.matches = [];
157+
scope.$digest();
158+
}
159+
});
160+
161+
var tplElCompiled = $compile("<typeahead-popup matches='matches' active='activeIdx' select='select(activeIdx)' "+
162+
"query='query'></typeahead-popup>")(scope);
163+
element.after(tplElCompiled);
164+
}
165+
};
166+
167+
}])
168+
169+
.directive('typeaheadPopup', function () {
170+
return {
171+
restrict:'E',
172+
scope:{
173+
matches:'=',
174+
query:'=',
175+
active:'=',
176+
select:'&'
177+
},
178+
replace:true,
179+
templateUrl:'template/typeahead/typeahead.html',
180+
link:function (scope, element, attrs) {
181+
182+
scope.isOpen = function () {
183+
return scope.matches.length > 0;
184+
};
185+
186+
scope.isActive = function (matchIdx) {
187+
return scope.active == matchIdx;
188+
};
189+
190+
scope.selectActive = function (matchIdx) {
191+
scope.active = matchIdx;
192+
};
193+
194+
scope.selectMatch = function (activeIdx) {
195+
scope.select({activeIdx:activeIdx});
Has a conversation. Original line has a conversation.
196+
};
197+
}
198+
};
199+
})
200+
201+
.filter('typeaheadHighlight', function() {
202+
return function(matchItem, query) {
203+
return (query) ? matchItem.replace(new RegExp(query, 'gi'), '<strong>$&</strong>') : query;
204+
};
205+
});

‎template/typeahead/typeahead.html

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div class="dropdown clearfix" ng-class="{open: isOpen()}">
2+
<ul class="typeahead dropdown-menu">
3+
<li ng-repeat="match in matches" ng-class="{active: isActive($index) }" ng-mouseenter="selectActive($index)">
4+
<a tabindex="-1" ng-click="selectMatch($index)" ng-bind-html-unsafe="match.label | typeaheadHighlight:query"></a>
5+
</li>
6+
</ul>
7+
</div>

0 commit comments

Comments
 (0)
This repository has been archived.