Commit 22ceff0f authored by Jono Mingard's avatar Jono Mingard Committed by Robert Lyon
Browse files

Improve accessibility of select2 (Bug #1492081)

- Modify the AJAX data adapter and search box so that items are
  read out when moving up and down through them with the keyboard
- Link select2 instances to the label(s) of the original <select>
- Allow removing tags with the keyboard
- Hide original <select> completely instead of leaving it
  accessible to screen readers
- Keep focus position when loading more elements with infinite
  scrolling
- Add behat step to set select2 value(s)

behatnotneeded

Change-Id: I6b380f74525c2ca3c38070744d71d1812cec05b0
parent 67685d07
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -13,3 +13,12 @@ Changes:
* Removed package manager metadata files
* Added styling to decrease size of text in the menus
* Added a 'readonly' status to an option to hide the 'x' (stop it being removable)
* Accessibility improvements
  * Modify the AJAX data adapter and search box so that items are read out when moving up and
    down through them with the keyboard
  * Link select2 instances to the label(s) of the original <select>
  * Allow removing tags with the keyboard
  * Hide original <select> completely instead of leaving it accessible to screen readers
  * Keep focus position when loading more elements with infinite scrolling
  * Add label to "Remove tag" button
  * Make "Remove tag" button work with the keyboard
+90 −44
Original line number Diff line number Diff line
@@ -781,8 +781,9 @@ S2.define('select2/results',[
    this.clear();
    this.hideLoading();

    var messageId = this.data.generateResultId({ id: this.$results.attr('id') }, {});
    var $message = $(
      '<li role="treeitem" aria-live="assertive"' +
      '<li id="' + messageId + '" role="treeitem" aria-live="assertive"' +
      ' class="select2-results__option"></li>'
    );

@@ -797,6 +798,7 @@ S2.define('select2/results',[
    $message[0].className += ' select2-results__message';

    this.$results.append($message);
    this.trigger('results:speak', { id: messageId });
  };

  Results.prototype.hideMessages = function () {
@@ -842,7 +844,7 @@ S2.define('select2/results',[
    return sorter(data);
  };

  Results.prototype.setClasses = function () {
  Results.prototype.setClasses = function (highlightIndex) {
    var self = this;

    this.data.current(function (selected) {
@@ -871,8 +873,10 @@ S2.define('select2/results',[

      var $selected = $options.filter('[aria-selected=true]');

      // Check if there are any selected options
      if ($selected.length > 0) {
      if (highlightIndex >= 0) {
        // If a specific index has been given, highlight it
        $options.eq(highlightIndex).trigger('mouseenter');
      } else if ($selected.length > 0) {
        // If there are selected options, highlight the first
        $selected.first().trigger('mouseenter');
      } else {
@@ -935,6 +939,14 @@ S2.define('select2/results',[
      delete attrs['aria-selected'];
    }

    if (data.element) {
        $element = $(data.element);
        if ($element.hasClass('readonly')) {
            var currentclass = option.getAttribute('class');
            option.setAttribute('class', 'readonly ' + currentclass);
        }
    }

    for (var attr in attrs) {
      var val = attrs[attr];

@@ -994,10 +1006,13 @@ S2.define('select2/results',[
    });

    container.on('results:append', function (params) {
      var previousIndex = self.$results
        .find('.select2-results__option[aria-selected]').length - 1;

      self.append(params.data);

      if (container.isOpen()) {
        self.setClasses();
        self.setClasses(previousIndex);
      }
    });

@@ -1175,10 +1190,12 @@ S2.define('select2/results',[

      if ($this.attr('aria-selected') === 'true') {
        if (self.options.get('multiple')) {
          if (!$this.hasClass('readonly')) {
            self.trigger('unselect', {
              originalEvent: evt,
              data: data
            });
          }
        } else {
          self.trigger('close', {});
        }
@@ -1203,6 +1220,9 @@ S2.define('select2/results',[
        data: data,
        element: $(this)
      });
      self.trigger('results:speak', {
        id: $(this).attr('id')
      });
    });
  };

@@ -1303,7 +1323,7 @@ S2.define('select2/selection/base',[
  BaseSelection.prototype.render = function () {
    var $selection = $(
      '<span class="select2-selection" role="combobox" ' +
      ' aria-haspopup="true" aria-expanded="false">' +
      'aria-haspopup="true" aria-expanded="false" aria-labelledby="' + this.options.labels.join(' ') + '">' +
      '</span>'
    );

@@ -1347,10 +1367,6 @@ S2.define('select2/selection/base',[
      }
    });

    container.on('results:focus', function (params) {
      self.$selection.attr('aria-activedescendant', params.data._resultId);
    });

    container.on('selection:update', function (params) {
      self.update(params.data);
    });
@@ -1568,23 +1584,9 @@ S2.define('select2/selection/multiple',[
    MultipleSelection.__super__.bind.apply(this, arguments);

    this.$selection.on('click', function (evt) {
      self.trigger('toggle', {
        originalEvent: evt
      });
    });

    this.$selection.on(
      'click',
      '.select2-selection__choice__remove',
      function (evt) {
        // Ignore the event if it is disabled
        if (self.options.get('disabled')) {
          return;
        }

        var $remove = $(this);
      var $remove = $(evt.target).closest('.select2-selection__choice__remove');
      if ($remove.length) {
        var $selection = $remove.parent();

        var data = $selection.data('data');

        self.trigger('unselect', {
@@ -1592,7 +1594,12 @@ S2.define('select2/selection/multiple',[
          data: data
        });
      }
    );
      else {
        self.trigger('toggle', {
          originalEvent: evt
        });
      }
    });
  };

  MultipleSelection.prototype.clear = function () {
@@ -1606,15 +1613,25 @@ S2.define('select2/selection/multiple',[
    return escapeMarkup(template(data, container));
  };

  MultipleSelection.prototype.selectionContainer = function (readonly) {
  MultipleSelection.prototype.selectionContainer = function (selection, readonly) {
    readonly = (typeof readonly !== 'undefined') ? readonly : false;
    var state = (readonly) ? 'readonly' : 'remove';
    var button = (readonly) ? '' : '&times;';
    var state, button;
    if (readonly) {
      state = 'readonly';
      button = '';
    }
    else {
      state = 'remove';
      button = '<span class="select2-hidden-accessible">' +
                 get_string_ajax('element.select.remove', 'pieforms', selection.text || selection.title) +
               '</span>&times;';
    }

    var $container = $(
      '<li class="select2-selection__choice">' +
        '<span class="select2-selection__choice__' + state + '" role="presentation">' +
        '<button class="select2-selection__choice__' + state + '">' +
          button +
        '</span>' +
        '</button>' +
      '</li>'
    );

@@ -1634,7 +1651,7 @@ S2.define('select2/selection/multiple',[
      var selection = data[d];

      var readonly = $(selection.element).hasClass('readonly');
      var $selection = this.selectionContainer(readonly);
      var $selection = this.selectionContainer(selection, readonly);
      var formatted = this.display(selection, $selection);

      $selection.append(formatted);
@@ -1862,6 +1879,10 @@ S2.define('select2/selection/search',[
      self.$search.attr('aria-activedescendant', params.id);
    });

    container.on('results:speak', function (params) {
      self.$search.attr('aria-activedescendant', params.id);
    });

    this.$selection.on('focusin', '.select2-search--inline', function (evt) {
      self.trigger('focus', evt);
    });
@@ -3432,6 +3453,11 @@ S2.define('select2/data/ajax',[
      var $request = options.transport(options, function (data) {
        var results = self.processResults(data, params);

        var items = results.results;
        for (var i = 0; i < items.length; i++) {
            items[i] = self._normalizeItem(items[i]);
        }

        if (self.options.get('debug') && window.console && console.error) {
          // Check to make sure that the response included a `results` key.
          if (!results || !results.results || !$.isArray(results.results)) {
@@ -3829,7 +3855,7 @@ S2.define('select2/dropdown/search',[
      '<span class="select2-search select2-search--dropdown">' +
        '<input class="select2-search__field" type="search" tabindex="-1"' +
        ' autocomplete="off" autocorrect="off" autocapitalize="off"' +
        ' spellcheck="false" role="textbox" />' +
        ' spellcheck="false" role="textbox" aria-autocomplete="list" />' +
      '</span>'
    );

@@ -4952,6 +4978,8 @@ S2.define('select2/core',[
      $element.data('select2').destroy();
    }

    var self = this;

    this.$element = $element;

    this.id = this._generateId($element);
@@ -4960,6 +4988,10 @@ S2.define('select2/core',[

    this.options = new Options(options, $element);

    if (this.options.labels == null) {
      this.options.labels = this._findOriginalLabels();
    }

    Select2.__super__.constructor.call(this);

    // Set up the tabindex
@@ -4997,8 +5029,6 @@ S2.define('select2/core',[

    // Bind events

    var self = this;

    // Bind the container to all of the adapters
    this._bindAdapters();

@@ -5020,8 +5050,7 @@ S2.define('select2/core',[
    });

    // Hide the original select
    $element.addClass('select2-hidden-accessible');
    $element.attr('aria-hidden', 'true');
    $element.hide();

    // Synchronize any monitored attributes
    this._syncAttributes();
@@ -5031,6 +5060,23 @@ S2.define('select2/core',[

  Utils.Extend(Select2, Utils.Observable);

  Select2.prototype._findOriginalLabels = function() {
    var self = this;
    var id = this.$element.attr('id');

    if (id) {
      return jQuery.map($('label[for="' + id + '"]'), function(el, i) {
        if (!$(el).attr('id')) {
          $(el).attr('id', self.id + '-label-' + i);
        }
        return $(el).attr('id');
      });
    }
    else {
      return [];
    }
  }

  Select2.prototype._generateId = function ($element) {
    var id = '';

@@ -5265,9 +5311,9 @@ S2.define('select2/core',[
        }
      } else {
        if (key === KEYS.ENTER || key === KEYS.SPACE ||
            (key === KEYS.DOWN && evt.altKey)) {
            (key === KEYS.DOWN && evt.altKey) &&
            $(evt.target).hasClass('select2-selection')) {
          self.open();

          evt.preventDefault();
        }
      }
+8 −2
Original line number Diff line number Diff line
@@ -218,12 +218,18 @@ Version: 4.0.0 Timestamp: Jul 1 13:43:36 EDT 2015
    text-overflow: ellipsis;
    width: auto; }
  .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
    background: none;
    border: none;
    color: #999;
    cursor: pointer;
    display: inline-block;
    font-weight: bold;
    margin-right: 2px; }
    .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover {
    margin-right: 2px;
    padding: 0; }
  .select2-container--default .select2-selection--multiple .select2-selection__choice__readonly {
    display: none; }
    .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover,
    .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:focus {
      color: #333; }

.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline {