From 6a97da2f8d81fc579f7044b18febc9ce7279800c Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Fri, 22 Feb 2013 22:30:13 +0100 Subject: [PATCH] feat(typeahead): add typeahead directive Closes #114 --- src/typeahead/docs/demo.html | 4 + src/typeahead/docs/demo.js | 5 + src/typeahead/docs/readme.md | 8 + src/typeahead/test/typeahead.spec.js | 309 +++++++++++++++++++++++++++ src/typeahead/typeahead.js | 205 ++++++++++++++++++ template/typeahead/typeahead.html | 7 + 6 files changed, 538 insertions(+) create mode 100644 src/typeahead/docs/demo.html create mode 100644 src/typeahead/docs/demo.js create mode 100644 src/typeahead/docs/readme.md create mode 100644 src/typeahead/test/typeahead.spec.js create mode 100644 src/typeahead/typeahead.js create mode 100644 template/typeahead/typeahead.html diff --git a/src/typeahead/docs/demo.html b/src/typeahead/docs/demo.html new file mode 100644 index 0000000000..d14f6a46e4 --- /dev/null +++ b/src/typeahead/docs/demo.html @@ -0,0 +1,4 @@ +
+
Model: {{selected| json}}
+ +
\ No newline at end of file diff --git a/src/typeahead/docs/demo.js b/src/typeahead/docs/demo.js new file mode 100644 index 0000000000..370655c956 --- /dev/null +++ b/src/typeahead/docs/demo.js @@ -0,0 +1,5 @@ +function TypeaheadCtrl($scope) { + + $scope.selected = undefined; + $scope.states = ['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']; +} \ No newline at end of file diff --git a/src/typeahead/docs/readme.md b/src/typeahead/docs/readme.md new file mode 100644 index 0000000000..e88cef964f --- /dev/null +++ b/src/typeahead/docs/readme.md @@ -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 \ No newline at end of file diff --git a/src/typeahead/test/typeahead.spec.js b/src/typeahead/test/typeahead.spec.js new file mode 100644 index 0000000000..636de0eb2f --- /dev/null +++ b/src/typeahead/test/typeahead.spec.js @@ -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("
")(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("
")(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("
")(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("
"); + expect(element).toBeClosed(); + }); + + it('should not get open on model change', function () { + var element = prepareInputEl("
"); + $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("
"); + 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("
"); + changeInputValueTo(element, 'b'); + expect(element).toBeClosed(); + }); + + it('should support custom model selecting function', function () { + $scope.updaterFn = function(selectedItem) { + return 'prefix' + selectedItem; + }; + var element = prepareInputEl("
"); + 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("
"); + changeInputValueTo(element, 'fo'); + var matchHighlight = findMatches(element).find('a').html(); + expect(matchHighlight).toEqual('prefixfoo'); + }); + + }); + + describe('selecting a match', function () { + + it('should select a match on enter', function () { + + var element = prepareInputEl("
"); + 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("
"); + 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("
"); + 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'); + }); + }); + + }); +}); \ No newline at end of file diff --git a/src/typeahead/typeahead.js b/src/typeahead/typeahead.js new file mode 100644 index 0000000000..cd665309ad --- /dev/null +++ b/src/typeahead/typeahead.js @@ -0,0 +1,205 @@ +angular.module('ui.bootstrap.typeahead', []) + +/** + * A helper service that can parse typeahead's syntax (string provided by users) + * Extracted to a separate service for ease of unit testing + */ + .factory('typeaheadParser', ['$parse', function ($parse) { + + // 00000111000000000000022200000000000000003333333333333330000000000044000 + var TYPEAHEAD_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/; + + return { + parse:function (input) { + + var match = input.match(TYPEAHEAD_REGEXP), modelMapper, viewMapper, source; + if (!match) { + throw new Error( + "Expected typeahead specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + + " but got '" + input + "'."); + } + + return { + itemName:match[3], + source:$parse(match[4]), + viewMapper:$parse(match[2] || match[1]), + modelMapper:$parse(match[1]) + }; + } + }; +}]) + + //options - min length + .directive('typeahead', ['$compile', '$q', 'typeaheadParser', function ($compile, $q, typeaheadParser) { + + var HOT_KEYS = [9, 13, 27, 38, 40]; + + return { + require:'ngModel', + link:function (originalScope, element, attrs, modelCtrl) { + + var selected = modelCtrl.$modelValue; + + //minimal no of characters that needs to be entered before typeahead kicks-in + var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1; + + //expressions used by typeahead + var parserResult = typeaheadParser.parse(attrs.typeahead); + + //create a child scope for the typeahead directive so we are not polluting original scope + //with typeahead-specific data (matches, query etc.) + var scope = originalScope.$new(); + originalScope.$on('$destroy', function(){ + scope.$destroy(); + }); + + var resetMatches = function() { + scope.matches = []; + scope.activeIdx = -1; + }; + + var getMatchesAsync = function(inputValue) { + + var locals = {$viewValue: inputValue}; + $q.when(parserResult.source(scope, locals)).then(function(matches) { + + //it might happen that several async queries were in progress if a user were typing fast + //but we are interested only in responses that correspond to the current view value + if (inputValue === modelCtrl.$viewValue) { + if (matches.length > 0) { + + scope.activeIdx = 0; + scope.matches.length = 0; + + //transform labels + for(var i=0; i= minSearch) { + getMatchesAsync(inputValue); + } + } + + return undefined; + }); + + modelCtrl.$render = function() { + var locals = {}; + if (modelCtrl.$viewValue) { + locals[parserResult.itemName] = modelCtrl.$viewValue; + element.val(parserResult.viewMapper(scope, locals)); + } + }; + + scope.select = function (activeIdx) { + //called from within the $digest() cycle + var locals = {}; + locals[parserResult.itemName] = scope.matches[activeIdx].model; + + selected = parserResult.modelMapper(scope, locals); + modelCtrl.$setViewValue(selected); + modelCtrl.$render(); + }; + + //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(9) + element.bind('keydown', function (evt) { + + //typeahead is open and an "interesting" key was pressed + if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { + return; + } + + evt.preventDefault(); + + if (evt.which === 40) { + scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; + scope.$digest(); + + } else if (evt.which === 38) { + scope.activeIdx = (scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1; + scope.$digest(); + + } else if (evt.which === 13 || evt.which === 9) { + scope.$apply(function () { + scope.select(scope.activeIdx); + }); + + } else if (evt.which === 27) { + scope.matches = []; + scope.$digest(); + } + }); + + var tplElCompiled = $compile("")(scope); + element.after(tplElCompiled); + } + }; + +}]) + + .directive('typeaheadPopup', function () { + return { + restrict:'E', + scope:{ + matches:'=', + query:'=', + active:'=', + select:'&' + }, + replace:true, + templateUrl:'template/typeahead/typeahead.html', + link:function (scope, element, attrs) { + + scope.isOpen = function () { + return scope.matches.length > 0; + }; + + scope.isActive = function (matchIdx) { + return scope.active == matchIdx; + }; + + scope.selectActive = function (matchIdx) { + scope.active = matchIdx; + }; + + scope.selectMatch = function (activeIdx) { + scope.select({activeIdx:activeIdx}); + }; + } + }; + }) + + .filter('typeaheadHighlight', function() { + return function(matchItem, query) { + return (query) ? matchItem.replace(new RegExp(query, 'gi'), '$&') : query; + }; + }); \ No newline at end of file diff --git a/template/typeahead/typeahead.html b/template/typeahead/typeahead.html new file mode 100644 index 0000000000..8d4a6908b5 --- /dev/null +++ b/template/typeahead/typeahead.html @@ -0,0 +1,7 @@ + \ No newline at end of file