Skip to content

Commit 79cdcc0

Browse files
committed
Fix focus wars in multiple selects
In the previous commit (02cca7b) support was added for multiple selects to automatically focus when they were tabbed into. While this did actually work, it caused a few bugs with the focus that prevented users from tabbing out of the container, effectively trapping keyboard users in Select2. This makes a few major changes to how things work in Select2, but should not break any backwards compatibility. - The internal `focus` event is now proxied through a `focus` method on the core object. This allows for two important things 1. The `focus` event will only be triggered if Select2 was in an unfocused state. 2. Select2 now (unofficially) supports the `select2('focus')` method again. But that does mean that it is possible to trigger the `focus` event now and not have it propagate throughout the widget. As it would previously trigger multiple times, even when Select2 had not actually lost focus, this is considered a fix to a bug instead of a breaking change. - The internal `blur` event in selections is only triggered when the focus is moved off of all elements within the selection. This allows for better tracking of where the focus is within Select2, but as a result of the asynchronous approach it does mean that the `blur` event is not necessarily synchronous and may be more difficult to trace. - On multiple selects, the standard selection container is never visually focused. Instead, the focus is always shifted over to the search box when it is requested. The tab index of the selection container is also always copied to the search box, so the search will always be in the tab order instead of the selection container. It's important to note that these changes to the tab order and how the focus is shifted do not apply to multiple selects that do not have a search box. Those changes also do not apply to single select boxes, which will still have the same focus and tabbing behaviours as they previously did.
1 parent 02cca7b commit 79cdcc0

3 files changed

Lines changed: 73 additions & 17 deletions

File tree

src/js/select2/core.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,16 @@ define([
214214

215215
Select2.prototype._registerSelectionEvents = function () {
216216
var self = this;
217-
var nonRelayEvents = ['toggle'];
217+
var nonRelayEvents = ['toggle', 'focus'];
218218

219219
this.selection.on('toggle', function () {
220220
self.toggleDropdown();
221221
});
222222

223+
this.selection.on('focus', function (params) {
224+
self.focus(params);
225+
});
226+
223227
this.selection.on('*', function (name, params) {
224228
if ($.inArray(name, nonRelayEvents) !== -1) {
225229
return;
@@ -264,10 +268,6 @@ define([
264268
self.$container.addClass('select2-container--disabled');
265269
});
266270

267-
this.on('focus', function () {
268-
self.$container.addClass('select2-container--focus');
269-
});
270-
271271
this.on('blur', function () {
272272
self.$container.removeClass('select2-container--focus');
273273
});
@@ -411,6 +411,20 @@ define([
411411
return this.$container.hasClass('select2-container--open');
412412
};
413413

414+
Select2.prototype.hasFocus = function () {
415+
return this.$container.hasClass('select2-container--focus');
416+
};
417+
418+
Select2.prototype.focus = function (data) {
419+
// No need to re-trigger focus events if we are already focused
420+
if (this.hasFocus()) {
421+
return;
422+
}
423+
424+
this.$container.addClass('select2-container--focus');
425+
this.trigger('focus');
426+
};
427+
414428
Select2.prototype.enable = function (args) {
415429
if (this.options.get('debug') && window.console && console.warn) {
416430
console.warn(

src/js/select2/selection/base.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ define([
4848
});
4949

5050
this.$selection.on('blur', function (evt) {
51-
self.trigger('blur', evt);
51+
self._handleBlur(evt);
5252
});
5353

5454
this.$selection.on('keydown', function (evt) {
@@ -95,6 +95,24 @@ define([
9595
});
9696
};
9797

98+
BaseSelection.prototype._handleBlur = function (evt) {
99+
var self = this;
100+
101+
// This needs to be delayed as the actve element is the body when the tab
102+
// key is pressed, possibly along with others.
103+
window.setTimeout(function () {
104+
// Don't trigger `blur` if the focus is still in the selection
105+
if (
106+
(document.activeElement == self.$selection[0]) ||
107+
($.contains(self.$selection[0], document.activeElement))
108+
) {
109+
return;
110+
}
111+
112+
self.trigger('blur', evt);
113+
}, 1);
114+
};
115+
98116
BaseSelection.prototype._attachCloseHandler = function (container) {
99117
var self = this;
100118

src/js/select2/selection/search.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ define([
2121

2222
var $rendered = decorated.call(this);
2323

24+
this._transferTabIndex();
25+
2426
return $rendered;
2527
};
2628

@@ -30,36 +32,34 @@ define([
3032
decorated.call(this, container, $container);
3133

3234
container.on('open', function () {
33-
self.$search.attr('tabindex', 0);
34-
35-
self.$search.focus();
35+
self.$search.trigger('focus');
3636
});
3737

3838
container.on('close', function () {
39-
self.$search.attr('tabindex', -1);
40-
4139
self.$search.val('');
42-
self.$search.focus();
40+
self.$search.trigger('focus');
4341
});
4442

4543
container.on('enable', function () {
4644
self.$search.prop('disabled', false);
45+
46+
self._transferTabIndex();
4747
});
4848

4949
container.on('disable', function () {
5050
self.$search.prop('disabled', true);
5151
});
5252

53-
this.$selection.on('focusin', '.select2-search--inline', function (evt) {
54-
self.trigger('focus', evt);
53+
container.on('focus', function (evt) {
54+
self.$search.trigger('focus');
5555
});
5656

57-
this.$selection.on('focus', function (evt) {
58-
self.$search.trigger('focus');
57+
this.$selection.on('focusin', '.select2-search--inline', function (evt) {
58+
self.trigger('focus', evt);
5959
});
6060

6161
this.$selection.on('focusout', '.select2-search--inline', function (evt) {
62-
self.trigger('blur', evt);
62+
self._handleBlur(evt);
6363
});
6464

6565
this.$selection.on('keydown', '.select2-search--inline', function (evt) {
@@ -95,10 +95,34 @@ define([
9595

9696
this.$selection.on('keyup.search input', '.select2-search--inline',
9797
function (evt) {
98+
var key = evt.which;
99+
100+
// We can freely ignore events from modifier keys
101+
if (key == KEYS.SHIFT || key == KEYS.CTRL || key == KEYS.ALT) {
102+
return;
103+
}
104+
105+
// Tabbing will be handled during the `keydown` phase
106+
if (key == KEYS.TAB) {
107+
return;
108+
}
109+
98110
self.handleSearch(evt);
99111
});
100112
};
101113

114+
/**
115+
* This method will transfer the tabindex attribute from the rendered
116+
* selection to the search box. This allows for the search box to be used as
117+
* the primary focus instead of the selection container.
118+
*
119+
* @private
120+
*/
121+
Search.prototype._transferTabIndex = function (decorated) {
122+
this.$search.attr('tabindex', this.$selection.attr('tabindex'));
123+
this.$selection.attr('tabindex', '-1');
124+
};
125+
102126
Search.prototype.createPlaceholder = function (decorated, placeholder) {
103127
this.$search.attr('placeholder', placeholder.text);
104128
};

0 commit comments

Comments
 (0)