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

fix(uiSelectCtrl): scroll to dropdown position on dropdown open #2034

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
],
Expand Down
150 changes: 92 additions & 58 deletions src/uiSelectController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
102 changes: 102 additions & 0 deletions test/select.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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: '[email protected]', group: 'Foo', age: 23 },
{ name: 'Ace', email: '[email protected]', group: 'Foo', age: 30 },
{ name: 'Mitchell', email: '[email protected]', 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);
}
});
});