From e5131d0cc8dffd98ba36a68f3d027bf79e763cb4 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 29 Jul 2019 22:34:24 -0400 Subject: [PATCH] Set the main ARIA 1.1 roles and properties for comboboxes (#5582) * Move search accessibility tests under selection tests * Set aria-activedescendent and aria-owns on selection search This is a reduced version of a5ab08b49cb which is split out to only set the `aria-activedescendent` and `aria-owns` attributes on the search box located within the selection container. This is the search box used within a multiple select, and previously it did not always set these two attributes correctly. One major change here is that we clear the `aria-activedescendent` attribute if the result that is selected does not have an ID. This was not being done previously, instead the attribute was still containing the old value, and it meant that sometimes the wrong result was being pointed to. The test coverage for this was also expanded to ensure that these attributes are properly being set. * Set aria-activedescendent and aria-owns on dropdown search This is a reduced version of a5ab08b49cb which is split out to only set the `aria-activedescendent` and `aria-owns` attributes on the search box located within the dropdown. This is the search box used within a single select, and previously it did not set these two attributes at all. Additionally, it did not set the `aria-autocomplete` attribute, which is also needed for screen readers to properly read through the list of results. There was previously no test coverage for this, so the tests were largely copied from the tests for selection search. * Set proper ARIA roles on result elements When Select2 4.0.0 was originally written, accessibility was tested using the Orca screen reader and Mozilla Firefox as the browser. Because a `` box as a tree view. Apparently Orca was the only screen reader to do this, but Select2 maintained this behaviour because the ARIA spec did not allow grouping elements for the right roles. In the ARIA 1.2 spec, an element with the role of `listbox` (which is the proper one for representing a `` element out of the box. As a result, instead of the Select2 results list being represented as a tree containing tree items, it is now represented as a listbox containing options and groups. Notices will be represented as an alert, which more closely represents what they were being used for. This is a reduced version of a5ab08b49cb which is split out to only fix the `role` attributes on elements within the results list. * Switch search boxes to have a role of searchbox I'm pretty sure this is implicit now, but since we used to specify that the search box had a role of `textbox`, we may as well migrate that over to specify the role of `searchbox`. This is different from the original pull request where this role was changes to `combobox`, but that is because we are working against the ARIA 1.2 spec and the original pull request was working agianst the ARIA 1.0 spec, which required the search box to have that role. * Set aria-controls instead of aria-owns on search boxes In ARIA 1.1, there was a switch to use `aria-controls` on the search box to point to the results list instead of using `aria-owns`. This is required because the `combobox`, in our case the selection container, should have the `aria-owns` attribute pointing to the results list. And because only one elment can own another element, we must fall back to `aria-controls` to represent that relationship. The tests have also been adjusted to reflect this new discovery. --- src/js/select2/dropdown/infiniteScroll.js | 2 +- src/js/select2/dropdown/search.js | 15 +- src/js/select2/results.js | 6 +- src/js/select2/selection/search.js | 12 +- tests/a11y/search-tests.js | 51 ----- tests/dropdown/search-a11y-tests.js | 185 ++++++++++++++++++ tests/results/a11y-tests.js | 25 +++ tests/results/option-tests.js | 55 +++++- tests/selection/search-a11y-tests.js | 217 ++++++++++++++++++++++ tests/unit-jq1.html | 4 +- tests/unit-jq2.html | 4 +- tests/unit-jq3.html | 4 +- 12 files changed, 518 insertions(+), 62 deletions(-) delete mode 100644 tests/a11y/search-tests.js create mode 100644 tests/dropdown/search-a11y-tests.js create mode 100644 tests/results/a11y-tests.js create mode 100644 tests/selection/search-a11y-tests.js diff --git a/src/js/select2/dropdown/infiniteScroll.js b/src/js/select2/dropdown/infiniteScroll.js index 00cc1340..f4c6c028 100644 --- a/src/js/select2/dropdown/infiniteScroll.js +++ b/src/js/select2/dropdown/infiniteScroll.js @@ -78,7 +78,7 @@ define([ var $option = $( '
  • ' + 'role="option" aria-disabled="true">' ); var message = this.options.get('translations').get('loadingMore'); diff --git a/src/js/select2/dropdown/search.js b/src/js/select2/dropdown/search.js index 78693892..2700e06d 100644 --- a/src/js/select2/dropdown/search.js +++ b/src/js/select2/dropdown/search.js @@ -11,7 +11,7 @@ define([ '' + '' + + ' spellcheck="false" role="searchbox" aria-autocomplete="list" />' + '' ); @@ -26,6 +26,8 @@ define([ Search.prototype.bind = function (decorated, container, $container) { var self = this; + var resultsId = container.id + '-results'; + decorated.call(this, container, $container); this.$search.on('keydown', function (evt) { @@ -48,6 +50,7 @@ define([ container.on('open', function () { self.$search.attr('tabindex', 0); + self.$search.attr('aria-controls', resultsId); self.$search.trigger('focus'); @@ -58,6 +61,8 @@ define([ container.on('close', function () { self.$search.attr('tabindex', -1); + self.$search.removeAttr('aria-controls'); + self.$search.removeAttr('aria-activedescendant'); self.$search.val(''); self.$search.trigger('blur'); @@ -80,6 +85,14 @@ define([ } } }); + + container.on('results:focus', function (params) { + if (params.data._resultId) { + self.$search.attr('aria-activedescendant', params.data._resultId); + } else { + self.$search.removeAttr('aria-activedescendant'); + } + }); }; Search.prototype.handleSearch = function (evt) { diff --git a/src/js/select2/results.js b/src/js/select2/results.js index e76d106b..c00f77dd 100644 --- a/src/js/select2/results.js +++ b/src/js/select2/results.js @@ -14,7 +14,7 @@ define([ Results.prototype.render = function () { var $results = $( - '' + '' ); if (this.options.get('multiple')) { @@ -37,7 +37,7 @@ define([ this.hideLoading(); var $message = $( - '
  • ' ); @@ -171,7 +171,7 @@ define([ option.className = 'select2-results__option'; var attrs = { - 'role': 'treeitem', + 'role': 'option', 'aria-selected': 'false' }; diff --git a/src/js/select2/selection/search.js b/src/js/select2/selection/search.js index 163a1817..dd889d2e 100644 --- a/src/js/select2/selection/search.js +++ b/src/js/select2/selection/search.js @@ -12,7 +12,7 @@ define([ '' ); @@ -29,14 +29,18 @@ define([ Search.prototype.bind = function (decorated, container, $container) { var self = this; + var resultsId = container.id + '-results'; + decorated.call(this, container, $container); container.on('open', function () { + self.$search.attr('aria-controls', resultsId); self.$search.trigger('focus'); }); container.on('close', function () { self.$search.val(''); + self.$search.removeAttr('aria-controls'); self.$search.removeAttr('aria-activedescendant'); self.$search.trigger('focus'); }); @@ -56,7 +60,11 @@ define([ }); container.on('results:focus', function (params) { - self.$search.attr('aria-activedescendant', params.id); + if (params.data._resultId) { + self.$search.attr('aria-activedescendant', params.data._resultId); + } else { + self.$search.removeAttr('aria-activedescendant'); + } }); this.$selection.on('focusin', '.select2-search--inline', function (evt) { diff --git a/tests/a11y/search-tests.js b/tests/a11y/search-tests.js deleted file mode 100644 index 58e56492..00000000 --- a/tests/a11y/search-tests.js +++ /dev/null @@ -1,51 +0,0 @@ -module('Accessibility - Search'); - -var MultipleSelection = require('select2/selection/multiple'); -var InlineSearch = require('select2/selection/search'); - -var $ = require('jquery'); - -var Utils = require('select2/utils'); -var Options = require('select2/options'); -var options = new Options({}); - -test('aria-autocomplete attribute is present', function (assert) { - var $select = $('#qunit-fixture .multiple'); - - var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); - var selection = new CustomSelection($select, options); - var $selection = selection.render(); - - // Update the selection so the search is rendered - selection.update([]); - - assert.equal( - $selection.find('input').attr('aria-autocomplete'), - 'list', - 'The search box is marked as autocomplete' - ); -}); - -test('aria-activedescendant should be removed when closed', function (assert) { - var $select = $('#qunit-fixture .multiple'); - - var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); - var selection = new CustomSelection($select, options); - var $selection = selection.render(); - - var container = new MockContainer(); - selection.bind(container, $('')); - - // Update the selection so the search is rendered - selection.update([]); - - var $search = $selection.find('input'); - $search.attr('aria-activedescendant', 'something'); - - container.trigger('close'); - - assert.ok( - !$search.attr('aria-activedescendant'), - 'There is no active descendant when the dropdown is closed' - ); -}); diff --git a/tests/dropdown/search-a11y-tests.js b/tests/dropdown/search-a11y-tests.js new file mode 100644 index 00000000..0876de9f --- /dev/null +++ b/tests/dropdown/search-a11y-tests.js @@ -0,0 +1,185 @@ +module('Dropdown - Search - Accessibility'); + +var Utils = require('select2/utils'); + +var Dropdown = require('select2/dropdown'); +var DropdownSearch = Utils.Decorate( + Dropdown, + require('select2/dropdown/search') +); + +var $ = require('jquery'); + +var Options = require('select2/options'); +var options = new Options({}); + +test('role attribute is set to searchbox', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + assert.equal( + $dropdown.find('input').attr('role'), + 'searchbox', + 'The search box is marked as a search box' + ); +}); + +test('aria-autocomplete attribute is present', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + assert.equal( + $dropdown.find('input').attr('aria-autocomplete'), + 'list', + 'The search box is marked as autocomplete' + ); +}); + +test('aria-activedescendant should not be set initiailly', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'The search box should not point to anything when it is first rendered' + ); +}); + +test('aria-activedescendant should be set after highlight', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + container.trigger('results:focus', { + data: { + _resultId: 'test' + } + }); + + var $search = $dropdown.find('input'); + + assert.equal( + $search.attr('aria-activedescendant'), + 'test', + 'The search is pointing to the focused result' + ); +}); + +test('activedescendant should remove if there is no ID', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + $search.attr('aria-activedescendant', 'test'); + + container.trigger('results:focus', { + data: {} + }); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'There is no result for the search to be pointing to' + ); +}); + +test('aria-activedescendant should be removed when closed', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + $search.attr('aria-activedescendant', 'something'); + + container.trigger('close'); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'There is no active descendant when the dropdown is closed' + ); +}); + +test('aria-controls should not be set initiailly', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + + assert.ok( + !$search.attr('aria-controls'), + 'The search box should not point to the results when it is first rendered' + ); +}); + +test('aria-controls should be set when opened', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + + container.trigger('open'); + + assert.ok( + $search.attr('aria-controls'), + 'The search should point to the results when it is opened' + ); +}); + +test('aria-controls should be removed when closed', function (assert) { + var $select = $('#qunit-fixture .single'); + + var dropdown = new DropdownSearch($select, options); + var $dropdown = dropdown.render(); + + var container = new MockContainer(); + dropdown.bind(container, $('')); + + var $search = $dropdown.find('input'); + $search.attr('aria-controls', 'something'); + + container.trigger('close'); + + assert.ok( + !$search.attr('aria-controls'), + 'There are no results for the search box to point to when it is closed' + ); +}); diff --git a/tests/results/a11y-tests.js b/tests/results/a11y-tests.js new file mode 100644 index 00000000..c5293265 --- /dev/null +++ b/tests/results/a11y-tests.js @@ -0,0 +1,25 @@ +module('Results - Accessibility'); + +var $ = require('jquery'); + +var Options = require('select2/options'); + +var Results = require('select2/results'); + +test('role of results should be a listbox', function (assert) { + var results = new Results($(''), new Options({})); + + var $results = results.render(); + + assert.equal($results.attr('role'), 'listbox'); +}); + +test('multiple select should have aria-multiselectable', function (assert) { + var results = new Results($(''), new Options({ + multiple: true + })); + + var $results = results.render(); + + assert.equal($results.attr('aria-multiselectable'), 'true'); +}); diff --git a/tests/results/option-tests.js b/tests/results/option-tests.js index f98937e7..96570501 100644 --- a/tests/results/option-tests.js +++ b/tests/results/option-tests.js @@ -60,4 +60,57 @@ test('option in disabled optgroup is disabled', function (assert) { }); assert.equal(option.getAttribute('aria-disabled'), 'true'); -}); \ No newline at end of file +}); + +test('options are not selected by default', function (assert) { + var results = new Results($(''), new Options({})); + + var $option = $(''); + var option = results.option({ + id: 'test', + element: $option[0] + }); + + assert.equal(option.getAttribute('aria-selected'), 'false'); +}); + +test('options with children are given the group role', function(assert) { + var results = new Results($(''), new Options({})); + + var $option = $(''); + var option = results.option({ + children: [{ + id: 'test' + }], + element: $option[0] + }); + + assert.equal(option.getAttribute('role'), 'group'); +}); + +test('options with children have the aria-label set', function (assert) { + var results = new Results($(''), new Options({})); + + var $option = $(''); + var option = results.option({ + children: [{ + id: 'test' + }], + element: $option[0], + text: 'test' + }); + + assert.equal(option.getAttribute('aria-label'), 'test'); +}); + +test('non-group options are given the option role', function (assert) { + var results = new Results($(''), new Options({})); + + var $option = $(''); + var option = results.option({ + id: 'test', + element: $option[0] + }); + + assert.equal(option.getAttribute('role'), 'option'); +}); diff --git a/tests/selection/search-a11y-tests.js b/tests/selection/search-a11y-tests.js new file mode 100644 index 00000000..b2628df5 --- /dev/null +++ b/tests/selection/search-a11y-tests.js @@ -0,0 +1,217 @@ +module('Selection containers - Inline search - Accessibility'); + +var MultipleSelection = require('select2/selection/multiple'); +var InlineSearch = require('select2/selection/search'); + +var $ = require('jquery'); + +var Utils = require('select2/utils'); +var Options = require('select2/options'); +var options = new Options({}); + +test('role attribute is set to searchbox', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + assert.equal( + $selection.find('input').attr('role'), + 'searchbox', + 'The search box is marked as a search box' + ); +}); + +test('aria-autocomplete attribute is present', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + assert.equal( + $selection.find('input').attr('aria-autocomplete'), + 'list', + 'The search box is marked as autocomplete' + ); +}); + +test('aria-activedescendant should not be set initiailly', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'The search box should not point to anything when it is first rendered' + ); +}); + +test('aria-activedescendant should be set after highlight', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + container.trigger('results:focus', { + data: { + _resultId: 'test' + } + }); + + var $search = $selection.find('input'); + + assert.equal( + $search.attr('aria-activedescendant'), + 'test', + 'The search is pointing to the focused result' + ); +}); + +test('activedescendant should remove if there is no ID', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + $search.attr('aria-activedescendant', 'test'); + + container.trigger('results:focus', { + data: {} + }); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'There is no result for the search to be pointing to' + ); +}); + +test('aria-activedescendant should be removed when closed', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + $search.attr('aria-activedescendant', 'something'); + + container.trigger('close'); + + assert.ok( + !$search.attr('aria-activedescendant'), + 'There is no active descendant when the dropdown is closed' + ); +}); + +test('aria-controls should not be set initiailly', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + + assert.ok( + !$search.attr('aria-controls'), + 'The search box should not point to the results when it is first rendered' + ); +}); + +test('aria-controls should be set when opened', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + + container.trigger('open'); + + assert.ok( + $search.attr('aria-controls'), + 'The search should point to the results when it is opened' + ); +}); + +test('aria-controls should be removed when closed', function (assert) { + var $select = $('#qunit-fixture .multiple'); + + var CustomSelection = Utils.Decorate(MultipleSelection, InlineSearch); + var selection = new CustomSelection($select, options); + var $selection = selection.render(); + + var container = new MockContainer(); + selection.bind(container, $('')); + + // Update the selection so the search is rendered + selection.update([]); + + var $search = $selection.find('input'); + $search.attr('aria-controls', 'something'); + + container.trigger('close'); + + assert.ok( + !$search.attr('aria-controls'), + 'There are no results for the search box to point to when it is closed' + ); +}); diff --git a/tests/unit-jq1.html b/tests/unit-jq1.html index bdbe1e04..dc2ca21e 100644 --- a/tests/unit-jq1.html +++ b/tests/unit-jq1.html @@ -57,7 +57,6 @@ - @@ -72,6 +71,7 @@ + @@ -81,6 +81,7 @@ + @@ -91,6 +92,7 @@ + diff --git a/tests/unit-jq2.html b/tests/unit-jq2.html index e3f23023..52500510 100644 --- a/tests/unit-jq2.html +++ b/tests/unit-jq2.html @@ -57,7 +57,6 @@ - @@ -72,6 +71,7 @@ + @@ -81,6 +81,7 @@ + @@ -91,6 +92,7 @@ + diff --git a/tests/unit-jq3.html b/tests/unit-jq3.html index d19a88df..d038818a 100644 --- a/tests/unit-jq3.html +++ b/tests/unit-jq3.html @@ -57,7 +57,6 @@ - @@ -72,6 +71,7 @@ + @@ -81,6 +81,7 @@ + @@ -91,6 +92,7 @@ +