Browse Source

Make the multi-select selection area more accessible (#5842)

* Move selection search out of list

The selection search was previously being injected into the list of
selections. This was largely done because it was easier to style
and it matched up wiht how we injected the search box in older
version of Select2. Unfortunately it was pointed out to us that
this breaks the semantics of the list of selections, which is
definitely accurate and impacts accessibility.

The search box for selections, used primarily in multiple selects,
was moved to be next to the list of selections. Functionally, this
does not impact Select2 at all because of how we manage focus, but
it does impact the accessibility and styling of Select2. This moves
closer to bringing the Select2 selection area for multiple selects
into an accessible state, by pushing for the selections (the options
that were previously selected) to be within their own container and
to not interfere with other elements that may be present. This is
important because screen readers need to be able to read the current
selections and differentiate between elments.

This impacts the styling of Select2 internally, without impacting
how it visually looks to our users. We have switched from floating
all of the options to keep them visually in line to using a set of
inline-block elements that better reflect what is actually happening.
As a result, the base stylng as well as the styling for the default
and classic themes has been adjusted to use a set of inline-block
elements while maintaining any spacing between elements that
previously existed. Because the DOM ordering has changed, anyone
who was relying on DOM order instead of classes for styling the
search box will need to update their CSS to reflect the new
ordering. The same applies to any plugins which were relying on the
DOM to be stable in order to make changes.

This also switches the selection search to take up the full width
of the container when the placeholder is visible. This fixes a
long-standing issue where it was possible for the placeholder on
a multiple select to be hidden depending on the state of the DOM
at the time the placeholder was rendered.

* Moved clear button out of rendered selection

The clear button was previously being injected at the start of the
rendered selection, creating invalid markup on multiple selects
where the rendered selection is a list, and overloading what the
rendered selection was for in single selects. It has now been moved
out of the rendered selection and it is now in front of the rendered
areas, allowing the clear button to still be floated to the right
over the rendered selection itself.

The clear button has also been changed to use a `<button>` element,
though it is still removed from the tab order. This allows for the
purpose to be properly communicated to screen readers while not
disrupting the natural tab order of the select box.

This affected the CSS across all themes because the positioning is
affected now that it is not located within the rendered container.
This required some small ajustments to get it to appear alongside
of the arrow within single select boxxes. This also required some
adjustments in order to get it to appear in the right location and
affect the rendering order within multiple selects, where the clear
button should push the search and selected choices down and around
it instead of appearing directly over it.

Because of the change to a `<button>` element, the clear icon may
render differently for some users because of the default stylesheet
rules. This may also come into play within themes where the `<button>`
elements may be styled differently.

* Add aria-describedby to selection search

This should make it more clear now what the current selections are
when the selection search is being used. In the future we may switch
this to pointing at a dedicated element which handles the accessibility
text representing the current selections, but for now we will rely
on the rendered selection to do the job.

* Set `type=button` on the "clear all" button

This will ensure that when the button is clicked, any form that the
select is contained within is not triggered. This is because buttons
default to `type=submit` within forms.

* Render choices inside additional span

This additional span is now being given a unique ID, similar to the
one which is given each result in the dropdown, and it is being
associated with the corresonding remove button for that choice. This
should not affect the rendering of the individual choices but it
does allow us to move forward on fixing accessibility issues around
the remove icon within multiple selects. Now when a remove icon is
focused by a screen reader, it should read out the text of the option
that would be removed.

* Make the remove item button an actual button

This moves the remove item icon from being a span that can be clicked
to being an actual button that acts and behaves like a real button.
This means it is now using a `<button>` element and can receive
keyboard events to trigger the removal of an item, as well as the
mouse events it could previously receive.

Because the selection area can receive keyboard events that behave
differently from the removal of an item, a new handler needed to be
added for the remove button that stops the propagation of keyboard
events for it. This allows the enter key to propagate like it would
previously and trigger the click event.

This required some heavy adjustments to the CSS that will affects
themes. the click area for the remove item button is now larger and
visaully is separated from the text of the item itself. The
separation is done by a solid line to the right of the button which
can be removed by CSS if the user desires. The background of the
remove item button also visually changes now when the button is
hovered or focused, instead of before where it was only the text
color that changed. The classic theme does not incorporate most of
these changes.

This also updates the RTL theme to work with all of the styling
changes that have been made so far.

* Add aria-describedby to clear button

The clear button currently says "Remove all items" but it does not
provide any context to screen readers what items will be removed.
By adding the `aria-describedby` attribute, they will now announce
the selected options after annoying that the button is to remove
all items.

* Hide the "x" in the remove icons from screen readers

Previously screen readers would hear the "x" be read aloud within
the different clear/remove icons even though it does not serve an
actual purpose for them. This hides them so now it should only read
out the label and that's it.

* Add helper text for the remove item button

This adds a title and aria-label attribute for the remove item
button. This should ensure that screen readers read out "Remove item"
followed by the item text when these icons are focused. This adds
a new translation for this text, but translations for languages
other than English will need to be added separately.

* Add aria-label for remove all button
Kevin Brown 5 years ago
parent
commit
181170f8a4

+ 3 - 0
src/js/select2/i18n/en.js

@@ -42,6 +42,9 @@ define(function () {
     },
     removeAllItems: function () {
       return 'Remove all items';
+    },
+    removeItem: function () {
+      return 'Remove item';
     }
   };
 });

+ 12 - 4
src/js/select2/selection/allowClear.js

@@ -92,21 +92,29 @@ define([
   AllowClear.prototype.update = function (decorated, data) {
     decorated.call(this, data);
 
+    this.$selection.find('.select2-selection__clear').remove();
+
     if (this.$selection.find('.select2-selection__placeholder').length > 0 ||
         data.length === 0) {
       return;
     }
 
+    var selectionId = this.$selection.find('.select2-selection__rendered')
+      .attr('id');
+
     var removeAll = this.options.get('translations').get('removeAllItems');
 
     var $remove = $(
-      '<span class="select2-selection__clear" title="' + removeAll() +'">' +
-        '&times;' +
-      '</span>'
+      '<button type="button" class="select2-selection__clear" tabindex="-1">' +
+        '<span aria-hidden="true">&times;</span>' +
+      '</button>'
     );
+    $remove.attr('title', removeAll());
+    $remove.attr('aria-label', removeAll());
+    $remove.attr('aria-describedby', selectionId);
     Utils.StoreData($remove[0], 'data', data);
 
-    this.$selection.find('.select2-selection__rendered').prepend($remove);
+    this.$selection.prepend($remove);
   };
 
   return AllowClear;

+ 43 - 4
src/js/select2/selection/multiple.js

@@ -26,6 +26,9 @@ define([
 
     MultipleSelection.__super__.bind.apply(this, arguments);
 
+    var id = container.id + '-container';
+    this.$selection.find('.select2-selection__rendered').attr('id', id);
+
     this.$selection.on('click', function (evt) {
       self.trigger('toggle', {
         originalEvent: evt
@@ -52,6 +55,19 @@ define([
         });
       }
     );
+
+    this.$selection.on(
+      'keydown',
+      '.select2-selection__choice__remove',
+      function (evt) {
+        // Ignore the event if it is disabled
+        if (self.isDisabled()) {
+          return;
+        }
+
+        evt.stopPropagation();
+      }
+    );
   };
 
   MultipleSelection.prototype.clear = function () {
@@ -70,9 +86,11 @@ define([
   MultipleSelection.prototype.selectionContainer = function () {
     var $container = $(
       '<li class="select2-selection__choice">' +
-        '<span class="select2-selection__choice__remove" role="presentation">' +
-          '&times;' +
-        '</span>' +
+        '<button type="button" class="select2-selection__choice__remove" ' +
+        'tabindex="-1">' +
+          '<span aria-hidden="true">&times;</span>' +
+        '</button>' +
+        '<span class="select2-selection__choice__display"></span>' +
       '</li>'
     );
 
@@ -88,13 +106,26 @@ define([
 
     var $selections = [];
 
+    var selectionIdPrefix = this.$selection.find('.select2-selection__rendered')
+      .attr('id') + '-choice-';
+
     for (var d = 0; d < data.length; d++) {
       var selection = data[d];
 
       var $selection = this.selectionContainer();
       var formatted = this.display(selection, $selection);
 
-      $selection.append(formatted);
+      var selectionId = selectionIdPrefix + Utils.generateChars(4) + '-';
+
+      if (selection.id) {
+        selectionId += selection.id;
+      } else {
+        selectionId += Utils.generateChars(4);
+      }
+
+      $selection.find('.select2-selection__choice__display')
+        .append(formatted)
+        .attr('id', selectionId);
 
       var title = selection.title || selection.text;
 
@@ -102,6 +133,14 @@ define([
         $selection.attr('title', title);
       }
 
+      var removeItem = this.options.get('translations').get('removeItem');
+
+      var $remove = $selection.find('.select2-selection__choice__remove');
+
+      $remove.attr('title', removeItem());
+      $remove.attr('aria-label', removeItem());
+      $remove.attr('aria-describedby', selectionId);
+
       Utils.StoreData($selection[0], 'data', selection);
 
       $selections.push($selection);

+ 10 - 11
src/js/select2/selection/search.js

@@ -9,11 +9,11 @@ define([
 
   Search.prototype.render = function (decorated) {
     var $search = $(
-      '<li class="select2-search select2-search--inline">' +
+      '<span class="select2-search select2-search--inline">' +
         '<input class="select2-search__field" type="search" tabindex="-1"' +
         ' autocorrect="off" autocapitalize="none"' +
         ' spellcheck="false" role="searchbox" aria-autocomplete="list" />' +
-      '</li>'
+      '</span>'
     );
 
     this.$searchContainer = $search;
@@ -24,6 +24,7 @@ define([
     var $rendered = decorated.call(this);
 
     this._transferTabIndex();
+    $rendered.append(this.$searchContainer);
 
     return $rendered;
   };
@@ -32,9 +33,12 @@ define([
     var self = this;
 
     var resultsId = container.id + '-results';
+    var selectionId = container.id + '-container';
 
     decorated.call(this, container, $container);
 
+    self.$search.attr('aria-describedby', selectionId);
+
     container.on('open', function () {
       self.$search.attr('aria-controls', resultsId);
       self.$search.trigger('focus');
@@ -88,8 +92,8 @@ define([
       var key = evt.which;
 
       if (key === KEYS.BACKSPACE && self.$search.val() === '') {
-        var $previousChoice = self.$searchContainer
-          .prev('.select2-selection__choice');
+        var $previousChoice = self.$selection
+          .find('.select2-selection__choice').last();
 
         if ($previousChoice.length > 0) {
           var item = Utils.GetData($previousChoice[0], 'data');
@@ -187,9 +191,6 @@ define([
 
     decorated.call(this, data);
 
-    this.$selection.find('.select2-selection__rendered')
-                   .append(this.$searchContainer);
-
     this.resizeSearch();
     if (searchHadFocus) {
       this.$search.trigger('focus');
@@ -222,11 +223,9 @@ define([
   Search.prototype.resizeSearch = function () {
     this.$search.css('width', '25px');
 
-    var width = '';
+    var width = '100%';
 
-    if (this.$search.attr('placeholder') !== '') {
-      width = this.$selection.find('.select2-selection__rendered').width();
-    } else {
+    if (this.$search.attr('placeholder') === '') {
       var minimumWidth = this.$search.val().length + 1;
 
       width = (minimumWidth * 0.75) + 'em';

+ 10 - 7
src/scss/_multiple.scss

@@ -10,22 +10,25 @@
   -webkit-user-select: none;
 
   .select2-selection__rendered {
-    display: inline-block;
-    overflow: hidden;
-    padding-left: 8px;
-    text-overflow: ellipsis;
-    white-space: nowrap;
+    display: inline;
+    list-style: none;
+    padding: 0;
+  }
+
+  .select2-selection__clear {
+    background-color: transparent;
+    border: none;
+    font-size: 1em;
   }
 }
 
 .select2-search--inline {
-  float: left;
-
   .select2-search__field {
     box-sizing: border-box;
     border: none;
     font-size: 100%;
     margin-top: 5px;
+    margin-left: 5px;
     padding: 0;
 
     &::-webkit-search-cancel-button {

+ 3 - 1
src/scss/_single.scss

@@ -20,7 +20,9 @@
   }
 
   .select2-selection__clear {
-    position: relative;
+    background-color: transparent;
+    border: none;
+    font-size: 1em;
   }
 }
 

+ 29 - 17
src/scss/theme/classic/_multiple.scss

@@ -8,46 +8,52 @@
 
   outline: 0;
 
+  padding-bottom: 5px;
+  padding-right: 5px;
+
   &:focus {
     border: 1px solid $focus-border-color;
   }
 
-  .select2-selection__rendered {
-    list-style: none;
-    margin: 0;
-    padding: 0 5px;
-  }
-
   .select2-selection__clear {
     display: none;
   }
 
   .select2-selection__choice {
     background-color: #e4e4e4;
-
     border: 1px solid $border-color;
     border-radius: $border-radius;
 
-    cursor: default;
+    display: inline-block;
+    margin-left: 5px;
+    margin-top: 5px;
+    padding: 0;
+  }
 
-    float: left;
+  .select2-selection__choice__display {
+    cursor: default;
 
-    margin-right: 5px;
-    margin-top: 5px;
-    padding: 0 5px;
+    padding-left: 2px;
+    padding-right: 5px;
   }
 
   .select2-selection__choice__remove {
+    background-color: transparent;
+    border: none;
+    border-top-left-radius: $border-radius;
+    border-bottom-left-radius: $border-radius;
+
     color: $remove-color;
     cursor: pointer;
 
-    display: inline-block;
+    font-size: 1em;
     font-weight: bold;
 
-    margin-right: 2px;
+    padding: 0 4px;
 
     &:hover {
       color: $remove-hover-color;
+      outline: none;
     }
   }
 }
@@ -55,14 +61,20 @@
 &[dir="rtl"] {
   .select2-selection--multiple {
     .select2-selection__choice {
-      float: right;
       margin-left: 5px;
       margin-right: auto;
     }
 
+    .select2-selection__choice__display {
+      padding-left: 5px;
+      padding-right: 2px;
+    }
+
     .select2-selection__choice__remove {
-      margin-left: 2px;
-      margin-right: auto;
+      border-top-left-radius: 0;
+      border-bottom-left-radius: 0;
+      border-top-right-radius: $border-radius;
+      border-bottom-right-radius: $border-radius;
     }
   }
 }

+ 2 - 1
src/scss/theme/classic/_single.scss

@@ -21,7 +21,8 @@
     cursor: pointer;
     float: right;
     font-weight: bold;
-    margin-right: 10px;
+    height: 26px;
+    margin-right: 20px;
   }
 
   .select2-selection__placeholder {

+ 41 - 27
src/scss/theme/default/_multiple.scss

@@ -3,25 +3,16 @@
   border: 1px solid #aaa;
   border-radius: 4px;
   cursor: text;
-
-  .select2-selection__rendered {
-    box-sizing: border-box;
-    list-style: none;
-    margin: 0;
-    padding: 0 5px;
-    width: 100%;
-
-    li {
-      list-style: none;
-    }
-  }
+  padding-bottom: 5px;
+  padding-right: 5px;
 
   .select2-selection__clear {
     cursor: pointer;
     float: right;
     font-weight: bold;
-    margin-top: 5px;
+    height: 20px;
     margin-right: 10px;
+    margin-top: 5px;
 
     // This padding is to account for the bottom border for the first
     // selection row and the top border of the second selection row.
@@ -32,46 +23,69 @@
 
   .select2-selection__choice {
     background-color: #e4e4e4;
-
     border: 1px solid #aaa;
     border-radius: 4px;
-    cursor: default;
-
-    float: left;
 
-    margin-right: 5px;
+    display: inline-block;
+    margin-left: 5px;
     margin-top: 5px;
-    padding: 0 5px;
+    padding: 0;
+  }
+
+  .select2-selection__choice__display {
+    cursor: default;
+
+    padding-left: 2px;
+    padding-right: 5px;
   }
 
   .select2-selection__choice__remove {
+    background-color: transparent;
+    border: none;
+    border-right: 1px solid #aaa;
+    border-top-left-radius: 4px;
+    border-bottom-left-radius: 4px;
+
     color: #999;
     cursor: pointer;
 
-    display: inline-block;
+    font-size: 1em;
     font-weight: bold;
 
-    margin-right: 2px;
+    padding: 0 4px;
 
-    &:hover {
+    &:hover, &:focus {
+      background-color: #f1f1f1;
       color: #333;
+      outline: none;
     }
   }
 }
 
 &[dir="rtl"] {
   .select2-selection--multiple {
-    .select2-selection__choice, .select2-search--inline {
-      float: right;
-    }
-
     .select2-selection__choice {
       margin-left: 5px;
       margin-right: auto;
     }
 
+    .select2-selection__choice__display {
+      padding-left: 5px;
+      padding-right: 2px;
+    }
+
     .select2-selection__choice__remove {
-      margin-left: 2px;
+      border-left: 1px solid #aaa;
+      border-right: none;
+      border-top-left-radius: 0;
+      border-bottom-left-radius: 0;
+      border-top-right-radius: 4px;
+      border-bottom-right-radius: 4px;
+    }
+
+    .select2-selection__clear {
+      float: left;
+      margin-left: 10px;
       margin-right: auto;
     }
   }

+ 3 - 0
src/scss/theme/default/_single.scss

@@ -12,6 +12,9 @@
     cursor: pointer;
     float: right;
     font-weight: bold;
+    height: 26px;
+    margin-right: 20px;
+    padding-right: 0px;
   }
 
   .select2-selection__placeholder {

+ 2 - 1
tests/options/translation-tests.js

@@ -52,7 +52,8 @@ test(
         'maximumSelected',
         'noResults',
         'searching',
-        'removeAllItems'
+        'removeAllItems',
+        'removeItem'
       ]
     );
   }

+ 2 - 2
tests/selection/search-placeholder-tests.js

@@ -43,8 +43,8 @@ test('width does not extend the search box', function (assert) {
 
     assert.equal(
       $search.outerWidth(),
-      60,
-      'The search should not be the entire width of the container'
+      100,
+      'The search should be the entire width of the container'
     );
 
     assert.equal(