diff --git a/karma.conf.js b/karma.conf.js index 89c684ca3..78920b538 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,6 +15,7 @@ module.exports = function(config) { 'node_modules/angular-mocks/angular-mocks.js', 'dist/select.js', + 'dist/select.css', 'test/helpers.js', 'test/**/*.spec.js' ], diff --git a/src/uiSelectController.js b/src/uiSelectController.js index d6023d0b9..8831be235 100644 --- a/src/uiSelectController.js +++ b/src/uiSelectController.js @@ -2,11 +2,12 @@ * Contains ui-select "intelligence". * * The goal is to limit dependency on the DOM whenever possible and - * put as much logic in the controller (instead of the link functions) as possible so it can be easily tested. + * put as much logic in the controller (instead of the link functions) as possible so it can be + * easily tested. */ uis.controller('uiSelectCtrl', - ['$scope', '$element', '$timeout', '$filter', '$$uisDebounce', 'uisRepeatParser', 'uiSelectMinErr', 'uiSelectConfig', '$parse', '$injector', '$window', - function($scope, $element, $timeout, $filter, $$uisDebounce, RepeatParser, uiSelectMinErr, uiSelectConfig, $parse, $injector, $window) { + ['$scope', '$element', '$timeout', '$filter', '$$uisDebounce', 'uisRepeatParser', 'uiSelectMinErr', 'uiSelectConfig', '$parse', '$injector', '$window', '$q', + function($scope, $element, $timeout, $filter, $$uisDebounce, RepeatParser, uiSelectMinErr, uiSelectConfig, $parse, $injector, $window, $q) { var ctrl = this; @@ -92,83 +93,115 @@ uis.controller('uiSelectCtrl', function _resetSearchInput() { if (ctrl.resetSearchInput) { ctrl.search = EMPTY_SEARCH; - //reset activeIndex - if (!ctrl.multiple) { - if (ctrl.selected && ctrl.items.length) { - ctrl.activeIndex = _findIndex(ctrl.items, function(item){ - return angular.equals(this, item); - }, ctrl.selected); - } else { - ctrl.activeIndex = 0; - } + } + + _resetActiveIndex(); + } + + function _resetActiveIndex() { + + if (!ctrl.multiple) { + if (ctrl.selected && ctrl.items.length) { + ctrl.activeIndex = _findIndex(ctrl.items, function(item){ + return angular.equals(this, item); + }, ctrl.selected); + } else { + ctrl.activeIndex = 0; } } } - function _groupsFilter(groups, groupNames) { - var i, j, result = []; - for(i = 0; i < groupNames.length ;i++){ - for(j = 0; j < groups.length ;j++){ - if(groups[j].name == [groupNames[i]]){ - result.push(groups[j]); - } + function _groupsFilter(groups, groupNames) { + var i, j, result = []; + for(i = 0; i < groupNames.length ;i++){ + for(j = 0; j < groups.length ;j++){ + if(groups[j].name == [groupNames[i]]){ + result.push(groups[j]); } } - return result; } + return result; + } // When the user clicks on ui-select, displays the dropdown list ctrl.activate = function(initSearchValue, avoidReset) { if (!ctrl.disabled && !ctrl.open) { - if(!avoidReset) _resetSearchInput(); - - $scope.$broadcast('uis:activate'); - ctrl.open = true; - ctrl.activeIndex = ctrl.activeIndex >= ctrl.items.length ? 0 : ctrl.activeIndex; - // ensure that the index is set to zero for tagging variants - // that where first option is auto-selected - if ( ctrl.activeIndex === -1 && ctrl.taggingLabel !== false ) { - ctrl.activeIndex = 0; - } - var container = $element.querySelectorAll('.ui-select-choices-content'); - var searchInput = $element.querySelectorAll('.ui-select-search'); - if (ctrl.$animate && ctrl.$animate.on && ctrl.$animate.enabled(container[0])) { - var animateHandler = function(elem, phase) { + _displayDropdown(initSearchValue, avoidReset); + } + else if (ctrl.open && !ctrl.searchEnabled) { + // Close the selection if we don't have search enabled, and we click on the select again + ctrl.close(); + } + }; + + function _displayDropdown(initSearchValue, avoidSearchReset) { + if(avoidSearchReset) { + _resetActiveIndex(); + } + else { + _resetSearchInput(); + } + + $scope.$broadcast('uis:activate'); + ctrl.open = true; + + ctrl.activeIndex = ctrl.activeIndex >= ctrl.items.length ? 0 : ctrl.activeIndex; + // ensure that the index is set to zero for tagging variants + // that where first option is auto-selected + if ( ctrl.activeIndex === -1 && ctrl.taggingLabel !== false ) { + ctrl.activeIndex = 0; + } + + var container = $element.querySelectorAll('.ui-select-choices-content'); + var searchInput = $element.querySelectorAll('.ui-select-search'); + + if (_canAnimate(container)) { + // Only focus input after the animation has finished + _animateDropdown(searchInput, container) + .then(_focusWhenReady.bind(null, initSearchValue)); + } else { + _focusWhenReady(initSearchValue); + } + } + + function _canAnimate(element) { + + return ctrl.$animate && ctrl.$animate.on && ctrl.$animate.enabled(element[0]); + } + + function _animateDropdown(searchInput, container) { + + return $q(function (resolve, reject) { + + var animateHandler = function (elem, phase) { if (phase === 'start' && ctrl.items.length === 0) { - // Only focus input after the animation has finished ctrl.$animate.off('removeClass', searchInput[0], animateHandler); - $timeout(function () { - ctrl.focusSearchInput(initSearchValue); - }); - } else if (phase === 'close') { - // Only focus input after the animation has finished + resolve(); + } + else if (phase === 'close') { ctrl.$animate.off('enter', container[0], animateHandler); - $timeout(function () { - ctrl.focusSearchInput(initSearchValue); - }); + resolve(); } }; if (ctrl.items.length > 0) { ctrl.$animate.on('enter', container[0], animateHandler); - } else { + } + else { ctrl.$animate.on('removeClass', searchInput[0], animateHandler); } - } else { - $timeout(function () { - ctrl.focusSearchInput(initSearchValue); - if(!ctrl.tagging.isActivated && ctrl.items.length > 1 && ctrl.open) { - _ensureHighlightVisible(); - } - }); - } - } - else if (ctrl.open && !ctrl.searchEnabled) { - // Close the selection if we don't have search enabled, and we click on the select again - ctrl.close(); + }); } - }; + + function _focusWhenReady(initSearchValue) { + $timeout(function () { + ctrl.focusSearchInput(initSearchValue); + if(!ctrl.tagging.isActivated && ctrl.items.length > 1 && ctrl.open) { + _ensureHighlightVisible(); + } + }); + } ctrl.focusSearchInput = function (initSearchValue) { ctrl.search = initSearchValue || ctrl.search; @@ -298,7 +331,8 @@ uis.controller('uiSelectCtrl', /** * Typeahead mode: lets the user refresh the collection using his own function. * - * See Expose $select.search for external / remote filtering https://github.com/angular-ui/ui-select/pull/31 + * See Expose $select.search for external / remote filtering + * https://github.com/angular-ui/ui-select/pull/31 */ ctrl.refresh = function(refreshAttr) { if (refreshAttr !== undefined) { diff --git a/test/select.spec.js b/test/select.spec.js index 7add0ade5..97f09e57d 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -3518,6 +3518,7 @@ describe('ui-select tests', function () { expect(scope.fetchFromServer.calls.any()).toEqual(true); }); }); + describe('Test key down key up and activeIndex should skip disabled choice', function () { it('should ignore disabled items, going down', function () { var el = createUiSelect({ uiDisableChoice: "person.age == 12" }); @@ -3629,4 +3630,105 @@ describe('ui-select tests', function () { }); }); }); + + describe('Test scrolling to highlighted item after opening', function () { + + beforeEach(function () { + + scope.people = scope.people.concat([ + { name: 'Elvis', email: 'elvis@email.com', group: 'Foo', age: 23 }, + { name: 'Ace', email: 'ace@email.com', group: 'Foo', age: 30 }, + { name: 'Mitchell', email: 'mitchell@email.com', group: 'Foo', age: 41 } + ]); + }); + + it('Should set ctrl.active index to the selected person index', function () { + + var lastIndex = scope.people.length - 1; + scope.selection.selected = scope.people[lastIndex]; + + var el = createUiSelect(); + clickMatch(el); + + expect(el.scope().$select.activeIndex).toEqual(lastIndex); + }); + + it('Should set ctrl.active index to the selected with `resetSearchInput = false`', function () { + + var lastIndex = scope.people.length - 1; + scope.selection.selected = scope.people[lastIndex]; + + var el = createUiSelect({ resetSearchInput: false}); + clickMatch(el); + + expect(el.scope().$select.activeIndex).toEqual(lastIndex); + }); + + it('Should scroll the last item into view with animation enabled', inject(function ($animate) { + + // This test should be updated with proper animation triggering and testing + // tried with `ngAnimateMock` but NO animation is triggered on digest or flush + + scope.selection.selected = scope.people.slice().pop(); + var el = createUiSelect(); + var choicesContentEl = $(el).find('.ui-select-choices-content').get(0); + + spyOn($animate, 'enabled').and.returnValue(true); + spyOn($animate, 'on'); + + clickMatch(el); + + var animationHandler = $animate.on.calls.mostRecent().args[2]; + animationHandler(choicesContentEl, 'close'); + $timeout.flush(); + + var optionEl = $(el).find('.ui-select-choices-row div:contains("Mitchell")').get(0); + + expect(choicesContentEl.scrollTop).toBeGreaterThan(0); + expect(isScrolledIntoContainer(choicesContentEl, optionEl)).toEqual(true); + })); + + it('Should scroll the last item into view with animation disabled', inject(function ($animate) { + + spyOn($animate, 'enabled').and.returnValue(false); + + scope.selection.selected = scope.people.slice().pop(); + + var el = createUiSelect(); + clickMatch(el); + $timeout.flush(); + + var choicesContentEl = $(el).find('.ui-select-choices-content').get(0); + var optionEl = $(el).find('.ui-select-choices-row div:contains("Mitchell")').get(0); + + expect(choicesContentEl.scrollTop).toBeGreaterThan(0); + expect(isScrolledIntoContainer(choicesContentEl, optionEl)).toEqual(true); + })); + + it('Should scroll the last item into view with `resetSearchInput = false`', function () { + + scope.selection.selected = scope.people.slice().pop(); + + var el = createUiSelect({ resetSearchInput: false}); + clickMatch(el); + $timeout.flush(); + + var choicesContentEl = $(el).find('.ui-select-choices-content').get(0); + var optionEl = $(el).find('.ui-select-choices-row div:contains("Mitchell")').get(0); + + expect(choicesContentEl.scrollTop).toBeGreaterThan(0); + expect(isScrolledIntoContainer(choicesContentEl, optionEl)).toEqual(true); + }); + + function isScrolledIntoContainer(container, item) + { + var scrollTop = container.scrollTop; + var scrollBottom = scrollTop + container.clientHeight; + + var itemTop = item.offsetTop; + var itemBottom = itemTop + item.clientHeight; + + return (scrollTop <= itemTop) && (itemBottom <= scrollBottom); + } + }); });