Commit 9510073d authored by Jacob Schatz's avatar Jacob Schatz Committed by Ruben Davila

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
......@@ -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) {
......@@ -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');
}
}
});
......@@ -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";
......@@ -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) {
......@@ -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, {
......@@ -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;
}
......@@ -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;
}
......@@ -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 {
......@@ -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);
......@@ -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) {
......@@ -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);
......@@ -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;
};
......@@ -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');
......@@ -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);
}
};
......@@ -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;
}
......@@ -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));
......@@ -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');
......@@ -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);
}
};
......
......@@ -7,7 +7,9 @@
KEYCODE = {
ESCAPE: 27,
BACKSPACE: 8,
ENTER: 13
ENTER: 13,
UP: 38,
DOWN: 40
};
function SearchAutocomplete(opts) {
......@@ -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();
......@@ -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() {
......
%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');
});
});
});
})();
......@@ -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() {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment