KyleMit
KyleMit

Reputation: 29829

Select current item when tabbing off Select2 input

This has been the subject of the following similar SO Question and several github issues including:

But the suggested solutions or questions have treated all blur events equally, regardless of how they were invoked. With most answers leveraging Automatic selection by setting selectOnClose.

Ideally, clicking off the dropdown (escaping) after merely hovering over options should not change the value:

SelectOnClose violation of principle of least surprise

How can you update the selection on tabout, but not other close events?

Here's an MCVE in jsFiddle and StackSnippets:

$('.select2').select2({});
<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.css" rel="stylesheet"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.js"></script>

<div class="form-control">
  <label for="foods2">Select2</label>
  <select id="foods2" class="select2" >
    <option value="1">Apple</option>
    <option value="2">Banana</option>
    <option value="3">Carrot</option>
    <option value="4">Donut</option>
  </select>
</div>

Upvotes: 1

Views: 2084

Answers (2)

Jesse Voogt
Jesse Voogt

Reputation: 1

I too found it unintuitive that tab selects the highlighted item when using a multiselect - for my single-select controls I have selectOnClose set to true so it didn't matter. My solution involves adding a capture phase event handler so that we are sure that our event handler will fire BEFORE the bubble phase handlers select2 uses. To add a capture phase event handler, you cannot use jQuery (although I do use jQuery inside the handler itself):

document.addEventListener('keydown', function (event) {
//adjust the following selector as needed - I am specifically targeting multiple selection
 if ($(event.target).is('.select2-container--open .select2-selection--multiple .select2-search__field')) 
 {
    if (event.key === 'Tab' || event.keyCode === 9) { //tab
        //just close the select window without changing the selection:
        $(event.target).closest('.select2').prev('select.select2-hidden-accessible').select2('close');
        event.stopImmediatePropagation();
        return false;
    }
 } 
}, { capture: true });

Upvotes: 0

KyleMit
KyleMit

Reputation: 29829

This could be trivially handled by modifying the original source code on line 323 which treats tabs and esc keys identically:

if (key === KEYS.ESC || key === KEYS.TAB || (key === KEYS.UP && evt.altKey)) {
    self.close();
    evt.preventDefault();
}

But third party libraries should ideally be modified with a pull request. So we have a couple problems in creating a wrapper for this. Principally, detecting a tab keypress event is hard. If we catch it too late, another action might have superseded it or it might be fired on another field altogether.

The landscape of capturing tab events and persisting information seems to fall into two buckets:

  1. Monitor before tab press and also after closed
  2. Intercept tab press and modify synchronously

In either case, we must know that a tab key was the offending item that caused the menu to close.

If we listen for keypress events with tab, on an open input, they'll either occur from .select2-selection if there's no search or select2-search__field if search is enabled.

$("body").on('keydown', e => { if (e.keyCode === 9) console.log(e.target) });

Select 2 Tab Events

However, if we setup as a delegated handler, as we have above, by the time the event bubbles up all the way up to "body", the menu has already closed, thus clearing the currently highlighted item and even our indicator of whether or not we started as open.

We can intercept before the menu closes by registering for the select2:closing event like this:

$("body").on('select2:closing', e => { console.log(e,e.target) });

However, select 2 doesn't persist the original event information and instead makes their own new jQueryEvent, so we don't yet know if we're closing due to a tab event (the body.keypress event fires afterward)


Solution

We'll monitor the select2:closing event and capture what we need to know. Next we need to attach a handler that listens for the subsequent firing of the initial click or a key stroke as the event pipeline is finished. We need to fire this once and only once for every close option. To do so we can use this extension $.fn.once. If it was raised by a tab, it'll update whatever value detected during closing. If not, that value and handler will disappear.

All told, it should look like this:

// monitor every time we're about to close a menu
$("body").on('select2:closing', function (e) {
  // save in case we want it
  var $sel2 = $(e.target).data("select2");
  var $sel = $sel2.$element;
  var $selDropdown = $sel2.$results.find(".select2-results__option--highlighted")
  var newValue =  $selDropdown.data("data").element.value;

  // must be closed by a mouse or keyboard - listen when that event is finished
  // this must fire once and only once for every possible menu close 
  // otherwise the handler will be sitting around with unintended side affects
  $("html").once('keyup mouseup', function (e) {

    // if close was due to a tab, use the highlighted value
    var KEYS = { UP: 38, DOWN: 40, TAB: 9 }
    if (e.keyCode === KEYS.TAB) {
      if (newValue != undefined) {
        $sel.val(newValue);
        $sel.trigger('change');
      }
    }

  });

});

$.fn.once = function (events, callback) {
    return this.each(function () {
        $(this).on(events, myCallback);
        function myCallback(e) {
            $(this).off(events, myCallback);
            callback.call(this, e);
        }
    });
};

Working demo in jsFiddle and StackSnippets:

$('.select2').select2({});

// monitor every time we're about to close a menu
$("body").on('select2:closing', function (e) {
	// save in case we want it
	var $sel2 = $(e.target).data("select2");
  var $sel = $sel2.$element;
  var $selDropdown = $sel2.$results.find(".select2-results__option--highlighted")
  var newValue =  $selDropdown.data("data").element.value;

  // must be closed by a mouse or keyboard - setup listener to see when that event is completely done
  // this must fire once and only once for every possible menu close 
  // otherwise the handler will be sitting around with unintended side affects
  $("html").once('keyup mouseup', function (e) {

    // if close was due to a tab, use the highlighted value
    var KEYS = { UP: 38, DOWN: 40, TAB: 9 }
    if (e.keyCode === KEYS.TAB) {
      if (newValue != undefined) {
        $sel.val(newValue);
        $sel.trigger('change');
      }
    }

  });
  
});

$.fn.once = function (events, callback) {
    return this.each(function () {
        $(this).on(events, myCallback);
        function myCallback(e) {
            $(this).off(events, myCallback);
            callback.call(this, e);
        }
    });
};
.form-control {
  padding:10px;
  display:inline-block;
}
select {
  width: 100px;
  border: 1px solid #aaa;
  border-radius: 4px;
  height: 28px;
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.css" rel="stylesheet"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.js"></script>

<div class="form-control">
  <label for="foods2">Select2</label>
  <select id="foods2" class="select2" >
    <option value="1">Apple</option>
    <option value="2">Banana</option>
    <option value="3">Carrot</option>
    <option value="4">Donut</option>
  </select>
</div>

Upvotes: 2

Related Questions