summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Schatz <jschatz@gitlab.com>2016-08-19 19:36:47 +0000
committerJacob Schatz <jschatz@gitlab.com>2016-08-19 19:36:47 +0000
commitb06f9211b0c3a90200c030e84ae425778bac6412 (patch)
tree6427f58b2012b205b707edc8d63ada51546ffa08
parentc711fe15dd2409161702f20a1ccffd861ae798ab (diff)
parent8ade8258603586560fad50784c23e4772706973f (diff)
downloadgitlab-ce-b06f9211b0c3a90200c030e84ae425778bac6412.tar.gz
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
-rw-r--r--app/assets/javascripts/gl_dropdown.js74
-rw-r--r--app/assets/javascripts/search_autocomplete.js18
-rw-r--r--spec/javascripts/fixtures/gl_dropdown.html.haml16
-rw-r--r--spec/javascripts/gl_dropdown_spec.js.es6119
-rw-r--r--spec/javascripts/search_autocomplete_spec.js8
5 files changed, 196 insertions, 39 deletions
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index d3394fae3f9..f85aa6dd7b7 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -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);
@@ -494,11 +501,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;
};
@@ -520,17 +532,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;
fieldName = this.options.fieldName;
@@ -609,13 +610,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);
}
};
@@ -623,7 +626,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;
}
@@ -651,7 +654,7 @@
return false;
}
if (currentKeyCode === 13 && currentIndex !== -1) {
- return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1);
+ return _this.selectRowAtIndex(currentIndex);
}
};
})(this));
@@ -661,6 +664,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');
@@ -674,10 +682,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);
}
};
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 990f6536eb2..227e8c696b4 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -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() {
diff --git a/spec/javascripts/fixtures/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml
new file mode 100644
index 00000000000..a20390c08ee
--- /dev/null
+++ b/spec/javascripts/fixtures/gl_dropdown.html.haml
@@ -0,0 +1,16 @@
+%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
diff --git a/spec/javascripts/gl_dropdown_spec.js.es6 b/spec/javascripts/gl_dropdown_spec.js.es6
new file mode 100644
index 00000000000..b529ea6458d
--- /dev/null
+++ b/spec/javascripts/gl_dropdown_spec.js.es6
@@ -0,0 +1,119 @@
+/*= 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');
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index 68d64483d67..324f5152780 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -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() {