Skip to content
Snippets Groups Projects
Commit 9510073d authored by Jacob Schatz's avatar Jacob Schatz Committed by Ruben Davila
Browse files

Merge branch 'lbennett/gitlab-ce-17465-search-for-project-with-cursor-keys' into 'master'

17465 Fixed dropdown cursor key navigation

## What does this MR do?
This MR fixes the use of cursor/arrow/enter key events with search dropdowns, allowing a user to navigate up and down the list with the arrow keys and then select their item with the enter key.
It also applies some *minor* scroll user experience fixes, such as resetting the selected dropdown item every time it opens/closes (also stops multiple dropdowns conflicting) and forcing the dropdown scroll to scroll right to the top or bottom depending on whether they have selected the first or last item, respectively.

## Are there points in the code the reviewer needs to double check?
I would like someone with GitLab experience to check over whether this would harm any unique implementations of the GitLabDropdown or SearchAutocomplete.

## Why was this MR needed?
The current version has incorrectly behaving search dropdowns in the navbar, they either do not navigate using the keyboard or do not use the enter keystroke to select a highlighted item.

## What are the relevant issue numbers?
Fixes #17465.
Closes #20752.
Closes #21014.
**Contributes** to #20754.

## Screenshots (if relevant)
![17465.mp4](/uploads/1145abec226036abbaaa4aa46020f52b/17465.mp4)

See merge request !4781
parent 92d568ac
No related branches found
No related tags found
1 merge request!8889WIP: Port of 25624-anticipate-obstacles-to-removing-turbolinks to EE.
Pipeline #
Loading
Loading
@@ -31,9 +31,8 @@
this.input
.on('keydown', function (e) {
var keyCode = e.which;
if (keyCode === 13) {
e.preventDefault()
e.preventDefault();
}
})
.on('keyup', function(e) {
Loading
Loading
@@ -111,9 +110,9 @@
matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
return $el.show();
return $el.show().removeClass('option-hidden');
} else {
return $el.hide();
return $el.hide().addClass('option-hidden');
}
}
});
Loading
Loading
@@ -179,7 +178,7 @@
})();
 
GitLabDropdown = (function() {
var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex;
var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, currentIndex;
 
LOADING_CLASS = "is-loading";
 
Loading
Loading
@@ -191,6 +190,12 @@
 
currentIndex = -1;
 
NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link, .option-hidden';
SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ")";
CURSOR_SELECT_SCROLL_PADDING = 5
FILTER_INPUT = '.dropdown-input .dropdown-input-field';
 
function GitLabDropdown(el1, options) {
Loading
Loading
@@ -213,6 +218,7 @@
if (this.options.data) {
if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
this.fullData = this.options.data;
currentIndex = -1;
this.parseData(this.options.data);
} else {
this.remote = new GitLabDropdownRemote(this.options.data, {
Loading
Loading
@@ -240,7 +246,7 @@
keys: searchFields,
elements: (function(_this) {
return function() {
selector = '.dropdown-content li:not(.divider)';
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
Loading
Loading
@@ -256,7 +262,7 @@
return function(data) {
_this.parseData(data);
if (_this.filterInput.val() !== '') {
selector = '.dropdown-content li:not(.divider):visible';
selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
Loading
Loading
@@ -376,7 +382,7 @@
var $target;
if (this.options.multiSelect) {
$target = $(e.target);
if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
e.stopPropagation();
return false;
} else {
Loading
Loading
@@ -387,7 +393,7 @@
 
GitLabDropdown.prototype.opened = function() {
var contentHtml;
currentIndex = -1;
this.resetRows();
this.addArrowKeyEvent();
if (this.options.setIndeterminateIds) {
this.options.setIndeterminateIds.call(this);
Loading
Loading
@@ -410,6 +416,7 @@
 
GitLabDropdown.prototype.hidden = function(e) {
var $input;
this.resetRows();
this.removeArrayKeyEvent();
$input = this.dropdown.find(".dropdown-input-field");
if (this.options.filterable) {
Loading
Loading
@@ -463,7 +470,7 @@
return "<li class='separator'></li>";
}
if (data.header != null) {
return "<li class='dropdown-header'>" + data.header + "</li>";
return _.template('<li class="dropdown-header"><%- header %></li>')({ header: data.header });
}
if (this.options.renderRow) {
html = this.options.renderRow.call(this.options, data, this);
Loading
Loading
@@ -495,11 +502,16 @@
text = this.highlightTextMatches(text, this.filterInput.val());
}
if (group) {
groupAttrs = "data-group='" + group + "' data-index='" + index + "'";
groupAttrs = 'data-group=' + group + ' data-index=' + index;
} else {
groupAttrs = '';
}
html = "<li> <a href='" + url + "' " + groupAttrs + " class='" + cssClass + "'> " + text + " </a> </li>";
html = _.template('<li><a href="<%- url %>" <%- groupAttrs %> class="<%- cssClass %>"><%= text %></a></li>')({
url: url,
groupAttrs: groupAttrs,
cssClass: cssClass,
text: text
});
}
return html;
};
Loading
Loading
@@ -521,17 +533,6 @@
return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>";
};
 
GitLabDropdown.prototype.highlightRow = function(index) {
var selector;
if (this.filterInput.val() !== "") {
selector = '.dropdown-content li:first-child a';
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one .dropdown-content li:first-child a";
}
return this.getElement(selector).addClass('is-focused');
}
};
GitLabDropdown.prototype.rowClicked = function(el) {
var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value;
isInput = $(this.el).is('input');
Loading
Loading
@@ -612,13 +613,15 @@
 
GitLabDropdown.prototype.selectRowAtIndex = function(index) {
var $el, selector;
selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a";
selector = SELECTABLE_CLASSES + ":eq(" + index + ") a";
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
$el = $(selector, this.dropdown);
if ($el.length) {
return $el.first().trigger('click');
$el.first().trigger('click');
var href = $el.attr('href');
if (href && href !== '#') Turbolinks.visit(href);
}
};
 
Loading
Loading
@@ -626,7 +629,7 @@
var $input, ARROW_KEY_CODES, selector;
ARROW_KEY_CODES = [38, 40];
$input = this.dropdown.find(".dropdown-input-field");
selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator):visible';
selector = SELECTABLE_CLASSES;
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
Loading
Loading
@@ -654,7 +657,7 @@
return false;
}
if (currentKeyCode === 13 && currentIndex !== -1) {
return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1);
return _this.selectRowAtIndex(currentIndex);
}
};
})(this));
Loading
Loading
@@ -664,6 +667,11 @@
return $('body').off('keydown');
};
 
GitLabDropdown.prototype.resetRows = function resetRows() {
currentIndex = -1;
$('.is-focused', this.dropdown).removeClass('is-focused');
};
GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop;
$('.is-focused', this.dropdown).removeClass('is-focused');
Loading
Loading
@@ -677,10 +685,14 @@
listItemHeight = $listItem.outerHeight();
listItemTop = $listItem.prop('offsetTop');
listItemBottom = listItemTop + listItemHeight;
if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom);
} else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
return $dropdownContent.scrollTop(listItemTop - dropdownContentTop);
if (!index) {
$dropdownContent.scrollTop(0)
} else if (index === ($listItems.length - 1)) {
$dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
} else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) {
$dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING);
} else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) {
return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING);
}
};
 
Loading
Loading
Loading
Loading
@@ -7,7 +7,9 @@
KEYCODE = {
ESCAPE: 27,
BACKSPACE: 8,
ENTER: 13
ENTER: 13,
UP: 38,
DOWN: 40
};
 
function SearchAutocomplete(opts) {
Loading
Loading
@@ -223,6 +225,12 @@
case KEYCODE.ESCAPE:
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
this.disableAutocomplete();
break;
case KEYCODE.UP:
case KEYCODE.DOWN:
return;
default:
if (this.searchInput.val() === '') {
this.disableAutocomplete();
Loading
Loading
@@ -319,9 +327,11 @@
};
 
SearchAutocomplete.prototype.disableAutocomplete = function() {
this.searchInput.addClass('disabled');
this.dropdown.removeClass('open');
return this.restoreMenu();
if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
this.searchInput.addClass('disabled');
this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
this.restoreMenu();
}
};
 
SearchAutocomplete.prototype.restoreMenu = function() {
Loading
Loading
%div
.dropdown.inline
%button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}}
Projects
%i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle
.dropdown-menu.dropdown-select.dropdown-menu-selectable
.dropdown-title
%span Go to project
%button.dropdown-title-button.dropdown-menu-close{aria: {label: 'Close'}}
%i.fa.fa-times.dropdown-menu-close-icon
.dropdown-input
%input.dropdown-input-field{type: 'search', placeholder: 'Filter results'}
%i.fa.fa-search.dropdown-input-search
.dropdown-content
.dropdown-loading
%i.fa.fa-spinner.fa-spin
/*= require jquery */
/*= require gl_dropdown */
/*= require turbolinks */
/*= require lib/utils/common_utils */
/*= require lib/utils/type_utility */
(() => {
const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link';
const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
const FOCUSED_ITEM_SELECTOR = `${ITEM_SELECTOR} a.is-focused`;
const ARROW_KEYS = {
DOWN: 40,
UP: 38,
ENTER: 13,
ESC: 27
};
let navigateWithKeys = function navigateWithKeys(direction, steps, cb, i) {
i = i || 0;
if (!i) direction = direction.toUpperCase();
$('body').trigger({
type: 'keydown',
which: ARROW_KEYS[direction],
keyCode: ARROW_KEYS[direction]
});
i++;
if (i <= steps) {
navigateWithKeys(direction, steps, cb, i);
} else {
cb();
}
};
describe('Dropdown', function describeDropdown() {
fixture.preload('gl_dropdown.html');
fixture.preload('projects.json');
beforeEach(() => {
fixture.load('gl_dropdown.html');
this.dropdownContainerElement = $('.dropdown.inline');
this.dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement);
this.projectsData = fixture.load('projects.json')[0];
this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({
selectable: true,
data: this.projectsData,
text: (project) => {
(project.name_with_namespace || project.name);
},
id: (project) => {
project.id;
}
});
});
afterEach(() => {
$('body').unbind('keydown');
this.dropdownContainerElement.unbind('keyup');
});
it('should open on click', () => {
expect(this.dropdownContainerElement).not.toHaveClass('open');
this.dropdownButtonElement.click();
expect(this.dropdownContainerElement).toHaveClass('open');
});
describe('that is open', () => {
beforeEach(() => {
this.dropdownButtonElement.click();
});
it('should select a following item on DOWN keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0);
let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0);
navigateWithKeys('down', randomIndex, () => {
expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement)).toHaveClass('is-focused');
});
});
it('should select a previous item on UP keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0);
navigateWithKeys('down', (this.projectsData.length - 1), () => {
expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0);
navigateWithKeys('up', randomIndex, () => {
expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1);
expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.dropdownMenuElement)).toHaveClass('is-focused');
});
});
});
it('should click the selected item on ENTER keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open')
let randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0
navigateWithKeys('down', randomIndex, () => {
spyOn(Turbolinks, 'visit').and.stub();
navigateWithKeys('enter', null, () => {
expect(this.dropdownContainerElement).not.toHaveClass('open');
let link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement);
expect(link).toHaveClass('is-active');
let linkedLocation = link.attr('href');
if (linkedLocation && linkedLocation !== '#') expect(Turbolinks.visit).toHaveBeenCalledWith(linkedLocation);
});
});
});
it('should close on ESC keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open');
this.dropdownContainerElement.trigger({
type: 'keyup',
which: ARROW_KEYS.ESC,
keyCode: ARROW_KEYS.ESC
});
expect(this.dropdownContainerElement).not.toHaveClass('open');
});
});
});
})();
Loading
Loading
@@ -105,13 +105,13 @@
a3 = "a[href='" + mrsAssignedToMeLink + "']";
a4 = "a[href='" + mrsIHaveCreatedLink + "']";
expect(list.find(a1).length).toBe(1);
expect(list.find(a1).text()).toBe(' Issues assigned to me ');
expect(list.find(a1).text()).toBe('Issues assigned to me');
expect(list.find(a2).length).toBe(1);
expect(list.find(a2).text()).toBe(" Issues I've created ");
expect(list.find(a2).text()).toBe("Issues I've created");
expect(list.find(a3).length).toBe(1);
expect(list.find(a3).text()).toBe(' Merge requests assigned to me ');
expect(list.find(a3).text()).toBe('Merge requests assigned to me');
expect(list.find(a4).length).toBe(1);
return expect(list.find(a4).text()).toBe(" Merge requests I've created ");
return expect(list.find(a4).text()).toBe("Merge requests I've created");
};
 
describe('Search autocomplete dropdown', function() {
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment