From 30532568bbdfb10b04c3641b6427f9db072a67bc Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Wed, 1 May 2019 14:05:52 +0100 Subject: [PATCH 1/7] Accessibility improvements for screen readers - Announce when a tag is selected or removed - Announce number of options available when filtering - Announce values of selected tags when widget is focused - Allow tag removal with delete key - Move aria-expanded from input element to widget div so expanded/collapsed state is announced --- src/jquery.tagger.css | 13 +++++++ src/jquery.tagger.js | 91 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/jquery.tagger.css b/src/jquery.tagger.css index 9023694..8138ac2 100644 --- a/src/jquery.tagger.css +++ b/src/jquery.tagger.css @@ -202,4 +202,17 @@ .tagger .suggestions ul li.addfreetext.focus { background-color: #c9f9c6; +} + +.tagger .tagger-audible-status { + border: 0px; + clip: rect(0px, 0px, 0px, 0px); + height: 1px; + margin-bottom: -1px; + margin-right: -1px; + overflow: hidden; + padding: 0px; + position: absolute; + white-space: nowrap; + width: 1px; } \ No newline at end of file diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index fdeffc7..53706cb 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -1,7 +1,7 @@ /* * jQuery UI Tagger * - * @version v0.8.1 (03/2017) + * @version v0.8.2 (05/2019) * * Copyright 2015, Fivium Ltd. * Released under the BSD 3-Clause license. @@ -13,9 +13,10 @@ * Authors: * Nick Palmer * Ben Basson + * Matt Eason * * Maintainer: - * Nick Palmer - nick.palmer@fivium.co.uk + * Matt Eason - matt.eason@fivium.co.uk * * Dependencies: * jQuery v1.9+ @@ -152,6 +153,7 @@ , UP: 38 , RIGHT: 39 , DOWN: 40 + , DELETE: 46 }, mouseCodes: { LEFT: 1 @@ -216,6 +218,7 @@ var originalElementID = this.element.prop('id'); this.taggerID = 'tagger' + originalElementID; this.suggestionsListID = 'suggestions' + originalElementID; + this.selectedTagsID = 'selectedTags' + originalElementID; // Construct tagger widget this.taggerWidget = $('
') @@ -224,7 +227,9 @@ .attr('role', 'combobox') .attr('aria-expanded', 'false') .attr('aria-autocomplete', 'list') + .attr('aria-haspopup', 'listbox') .attr('aria-owns', this.suggestionsListID) + .attr('aria-describedby', this.selectedTagsID) .insertAfter(this.element); if (this.element.attr('aria-label')) { @@ -254,6 +259,11 @@ this.taggerWidget.css('height', this.options.fieldHeight); } + this.taggerSelectedTags = $('
') + .attr('class', 'tagger-selected-tags') + .attr('id', this.selectedTagsID) + .appendTo(this.taggerWidget); + if (!this.readonly) { // Add the suggestion drop arrow and and text input if not readonly this.taggerInput = $('') @@ -262,6 +272,7 @@ .addClass('intxt') .attr('role', 'textbox') .attr('aria-label', 'Autocomplete input box') + .attr('aria-controls', this.suggestionsListID) .appendTo(this.taggerWidget); this.taggerButtonsPanel = $('
').addClass('tagger-buttons'); this.taggerButtonsPanel.appendTo(this.taggerWidget); @@ -270,6 +281,8 @@ this.taggerSuggestionsButton = $('
') .addClass('droparrow') .addClass('hittarget') + .attr('aria-label', 'Toggle option display') + .attr('role', 'button') .bind('mouseup keyup', $.proxy(this._handleSuggestionsButtonInteraction, this)) .appendTo(this.taggerButtonsPanel); $('') @@ -323,6 +336,14 @@ .addClass('clearer') .appendTo(this.taggerWidget); + // Audible status div lets us announce status changes to screen readers + this.audibleStatus = $('
') + .addClass('tagger-audible-status') + .attr('role', 'status') + .attr('aria-live', 'polite') + .attr('aria-atomic', 'true') + .appendTo(this.taggerWidget); + if (!this.readonly) { // If not readonly, stub out an empty suggestion list this.taggerSuggestions = $('
') @@ -707,7 +728,7 @@ this._inputExpand(this.taggerInput); // Clear filtered suggestions - this._loadSuggestions(this.tagsByID, true); + this._loadSuggestions(this.tagsByID, true, false); // Set the flag to show it's not loaded filtered results this.loadedFiltered = false; }, this), 250); @@ -752,7 +773,7 @@ } } // Load filtered results into the suggestion list - this._loadSuggestions(filteredResults, false); + this._loadSuggestions(filteredResults, false, true); this.loadedFiltered = true; }, @@ -796,7 +817,7 @@ } }); self.tagsByID = data; - self._loadSuggestions(data, false); + self._loadSuggestions(data, false, true); self.loadedFiltered = true; self._showSuggestions(false); }, @@ -933,9 +954,10 @@ * Load tags into the suggestion list * @param {object} suggestableTags - Object containing members of tagID to tag object * @param {boolean} allowIndent - Allow indenting of suggestion lists if true + * @param {boolean} setAudibleStatus - Set the audible status of the suggestions list if true * @protected */ - _loadSuggestions: function (suggestableTags, allowIndent) { + _loadSuggestions: function (suggestableTags, allowIndent, setAudibleStatus) { // Clear out suggestion list this.taggerSuggestionsList.children().remove(); @@ -992,6 +1014,8 @@ .appendTo(this.taggerSuggestionsList); } + var audibleStatusSet = false; + // Add message if filtering meant no items to suggest and the noSuggestText option is not empty and the user has actually typed something if (suggestableTagArray.length === 0) { if (this.options.noSuggestText.length > 0 && this._getVisibleInput().val().length > 0) { @@ -1002,6 +1026,11 @@ .addClass('missing') .text(this.options.noSuggestText) .appendTo(this.taggerSuggestionsList); + + if (setAudibleStatus) { + this._setAudibleStatus(this.options.noSuggestText); + audibleStatusSet = true; + } } } else if (this.taggerSuggestionsList.children().length === 0) { @@ -1013,6 +1042,11 @@ .addClass('missing') .text(this.options.emptyListText) .appendTo(this.taggerSuggestionsList); + + if (setAudibleStatus) { + this._setAudibleStatus(this.options.emptyListText); + audibleStatusSet = true; + } } if (suggestableTags.limited) { @@ -1023,6 +1057,16 @@ .addClass('limited') .text(this.options.limitedText) .appendTo(this.taggerSuggestionsList); + + if (setAudibleStatus) { + this._setAudibleStatus(this.options.limitedText); + audibleStatusSet = true; + } + } + + if (!audibleStatusSet && setAudibleStatus) { + // Announce the number of options available + this._setAudibleStatus(suggestableTagArray.length + ((suggestableTagArray.length === 1) ? ' option is' : ' options are') + ' available'); } }, @@ -1096,10 +1140,12 @@ var suggestionItem = $(event.target).closest('li'); if (suggestionItem.data('tagid') && !suggestionItem.data('freetext')) { this._addTagFromID(suggestionItem.data('tagid')); + this._setAudibleStatus("Selected " + this.tagsByID[suggestionItem.data('tagid')].key); this._selectionReset(true, true); } else if (suggestionItem.data('freetext') && !suggestionItem.data('tagid')) { this._addFreeText(suggestionItem.data('freetext')); + this._setAudibleStatus("Added " + this.tagsByID[suggestionItem.data('tagid')].key); this._selectionReset(true, true); } else { @@ -1234,7 +1280,7 @@ this._hideSuggestions(); } // Reload in all suggestions - this._loadSuggestions(this.tagsByID, true); + this._loadSuggestions(this.tagsByID, true, true); // Clear the flag this.loadedFiltered = false; } @@ -1256,7 +1302,7 @@ this.taggerSuggestions.show(); // Mark the aria expanded attr to true - this.taggerInput.attr('aria-expanded', 'true'); + this.taggerWidget.attr('aria-expanded', 'true'); // Show the filter if necessary if (this.singleValue && this.taggerFilterInput && this.tagCount === 1) { @@ -1268,7 +1314,7 @@ var self = this; var loadSuggestionsInternal = function () { - self._loadSuggestions(self.tagsByID, true); + self._loadSuggestions(self.tagsByID, true, true); // Set the flag to show it's not loaded filtered results self.loadedFiltered = false; // Focus the first item in the list, which may be the filter, or may be an option @@ -1308,7 +1354,7 @@ this.taggerSuggestions.hide(); // Mark the aria expanded attr to false - this.taggerInput.attr('aria-expanded', 'false'); + this.taggerWidget.attr('aria-expanded', 'false'); }, /** @@ -1358,7 +1404,7 @@ // Expand properly this._inputExpand(this.taggerInput); // Clear filtered suggestions - this._loadSuggestions(this.tagsByID, true); + this._loadSuggestions(this.tagsByID, true, !shouldHideMenu); // Set the flag to show it's not loaded filtered results this.loadedFiltered = false; // Focus input @@ -1416,15 +1462,16 @@ tag = $('
') .addClass('tag') .attr("tabindex", this.tabIndex) + .attr("aria-label", tagData.key) .text($('
').html(tagData.key).text()) .data("tagid", tagID) - .insertBefore(this.taggerInput); + .appendTo(this.taggerSelectedTags); if (tagData.freetext) { tag.addClass('freetext'); } - var tagRemover = $('Deselect tag'); + var tagRemover = $('Deselect tag'); // Reusable tag removal closure var tagRemoveProcessing = function () { @@ -1475,9 +1522,9 @@ } }, this)); - // Bind event to the whole tag to deal with backspaces, arrow keys + // Bind event to the whole tag to deal with backspaces, delete key and arrow keys tag.bind('keydown', $.proxy(function (event) { - if (event.which === this.keyCodes.BACKSPACE) { // Backspace + if (event.which === this.keyCodes.BACKSPACE || event.which === this.keyCodes.DELETE) { // Backspace or delete this._removeTagByElem($(event.target), false, true); if (tagRemover) { tagRemover.remove(); @@ -1528,7 +1575,7 @@ } } else { - tag = $('
').prependTo(this.taggerWidget); + tag = $('
').appendTo(this.taggerSelectedTags); tag.text($('
').html(tagData.key).text()); if (this.singleValue) { tag.addClass('tag-single'); @@ -1627,12 +1674,24 @@ this.taggerInput.show(); } + // Announce the removal + this._setAudibleStatus("Removed " + this.tagsByID[tagID].key); + // Fire onchange action if (this.canFireActions) { this._fireOnChangeAction(); } }, + /** + * Set the text in the audible status div, which will be read out by screen readers + * @param {String} status - the text to read out + * @protected + */ + _setAudibleStatus: function (status) { + this.audibleStatus.text(status); + }, + /** * If there is any onchange function defined on the original element, run it * @protected From 3b33901d0f59c62e163e62ce8ada0bfc6ec4dee0 Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Wed, 10 Jul 2019 14:35:44 +0100 Subject: [PATCH 2/7] Add support for alt+up/alt+down to expand/collapse. Don't expose dropdown arrow to assistive tech --- src/jquery.tagger.js | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index 53706cb..60b4ed1 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -281,8 +281,6 @@ this.taggerSuggestionsButton = $('
') .addClass('droparrow') .addClass('hittarget') - .attr('aria-label', 'Toggle option display') - .attr('role', 'button') .bind('mouseup keyup', $.proxy(this._handleSuggestionsButtonInteraction, this)) .appendTo(this.taggerButtonsPanel); $('') @@ -303,8 +301,6 @@ .appendTo(this.taggerSuggestionsButton); } - this.taggerSuggestionsButton.attr("tabindex", this.tabIndex); - // Add placeholder text to text input field if (this.options.placeholder !== null) { this.taggerInput.attr("placeholder", this.options.placeholder); @@ -325,6 +321,13 @@ // Select the widget itself again this._getWidgetFocusable().focus(); } + + if (event.target && event.altKey && (event.which === this.keyCodes.DOWN || event.which === this.keyCodes.UP)) { + this._toggleShowSuggestions(); + + // Select the widget itself again + this._getWidgetFocusable().focus(); + } }, this)); // Capture the keypress event for any child elements - redirect any chars to the current input field @@ -574,7 +577,7 @@ } } else if (event.which === this.keyCodes.DOWN) { // Down Arrow - if (isMainInput) { + if (isMainInput && !event.altKey) { if (!this.options.ajaxURL || this.taggerSuggestions.is(":visible")) { this._showSuggestions(true); } @@ -613,19 +616,28 @@ this.taggerWidget.find("input[tabindex]:visible").first().focus(); } else { - // If the suggestion list is visible already, then toggle it off - if (this.taggerSuggestions.is(":visible")) { - this._hideSuggestions(); - } - // otherwise show it - else { - this._showSuggestions(true); - } + this._toggleShowSuggestions(); } event.preventDefault(); } }, + /** + * Toggle whether suggestions are shown or not + * + * @private + */ + _toggleShowSuggestions: function() { + // If the suggestion list is visible already, then toggle it off + if (this.taggerSuggestions.is(":visible")) { + this._hideSuggestions(); + } + // otherwise show it + else { + this._showSuggestions(true); + } + }, + /** * When keypress events fire on the tagger widget redirect them to the filter input * From 139d443954efdd40d907479cb3798550455800dc Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Wed, 10 Jul 2019 14:37:15 +0100 Subject: [PATCH 3/7] Only use select element's prompt as aria-labelledby if it doesn't have an aria-label --- src/jquery.tagger.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index 60b4ed1..081646f 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -234,8 +234,7 @@ if (this.element.attr('aria-label')) { this.taggerWidget.attr('aria-label', this.element.attr('aria-label')); - } - if ($('label[for=' + this.element.prop('id') + ']')) { + } else if ($('label[for=' + this.element.prop('id') + ']')) { this.taggerWidget.attr('aria-labelledby', $('label[for=' + this.element.prop('id') + ']').first().prop('id')); } From 8630af2687e1b579cee3c8e719385334b5340edc Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Wed, 10 Jul 2019 16:58:04 +0100 Subject: [PATCH 4/7] Create proper aria-label for autocomplete input based on label/labelledby of original select --- src/jquery.tagger.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index 081646f..c4630f3 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -264,13 +264,21 @@ .appendTo(this.taggerWidget); if (!this.readonly) { + + var ariaLabel = "Autocomplete filter"; + if(this.taggerWidget.attr('aria-label')) { + ariaLabel += ' for ' + this.taggerWidget.attr('aria-label'); + } else if(this.taggerWidget.attr('aria-labelledby')) { + ariaLabel += ' for ' + $('#'+this.taggerWidget.attr('aria-labelledby')).text(); + } + // Add the suggestion drop arrow and and text input if not readonly this.taggerInput = $('') .attr('type', 'text') .attr('autocomplete', 'off') .addClass('intxt') .attr('role', 'textbox') - .attr('aria-label', 'Autocomplete input box') + .attr('aria-label', ariaLabel) .attr('aria-controls', this.suggestionsListID) .appendTo(this.taggerWidget); this.taggerButtonsPanel = $('
').addClass('tagger-buttons'); From 1cbf1d8d9c905b6af7c5917674b004b0d52a1acd Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Tue, 16 Jul 2019 11:04:22 +0100 Subject: [PATCH 5/7] Show suggestions when down arrow is pressed while root tagger element has focus --- src/jquery.tagger.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index c4630f3..1d501f8 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -320,8 +320,8 @@ // Set the tab index on the input field this.taggerInput.attr("tabindex", this.tabIndex); - // Esc should hide the tagger suggestions globally this.taggerWidget.bind('keydown', $.proxy(function (event) { + // Esc should hide the tagger suggestions globally if (event.target && event.which === this.keyCodes.ESC) { // Esc this._hideSuggestions(); @@ -329,12 +329,18 @@ this._getWidgetFocusable().focus(); } + // Alt+down and alt+up should toggle the suggestions list if (event.target && event.altKey && (event.which === this.keyCodes.DOWN || event.which === this.keyCodes.UP)) { this._toggleShowSuggestions(); // Select the widget itself again this._getWidgetFocusable().focus(); } + + // Down arrow shows suggestions list if not visible + if (event.target && !this.options.ajaxURL && !this.taggerSuggestions.is(":visible") && event.which === this.keyCodes.DOWN) { + this._showSuggestions(true); + } }, this)); // Capture the keypress event for any child elements - redirect any chars to the current input field From ed39ad7e237b53018e53884adc968e5db309e200 Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Tue, 16 Jul 2019 11:26:57 +0100 Subject: [PATCH 6/7] Ensure focus outline is shown in IE --- src/jquery.tagger.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jquery.tagger.css b/src/jquery.tagger.css index 8138ac2..10234c3 100644 --- a/src/jquery.tagger.css +++ b/src/jquery.tagger.css @@ -9,7 +9,7 @@ display: inline-block; } .tagger.focus { - outline: auto 5px #69CAE8; + outline: 2px solid #69CAE8; } .tagger-readonly { background-color: #ebebeb; From 55026f8ecb605b1894b27533d468d81603d960c8 Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Tue, 30 Jul 2019 16:06:51 +0100 Subject: [PATCH 7/7] Fix audible suggestion exceptions Fixed an issue in single-select mode where selecting an item while another is already selected raised an exception. Fixed another issue when adding a freetext item raised an exception --- src/jquery.tagger.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index 1d501f8..15b7253 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -1164,13 +1164,15 @@ // Handle suggestion adding var suggestionItem = $(event.target).closest('li'); if (suggestionItem.data('tagid') && !suggestionItem.data('freetext')) { - this._addTagFromID(suggestionItem.data('tagid')); - this._setAudibleStatus("Selected " + this.tagsByID[suggestionItem.data('tagid')].key); + var tagId = suggestionItem.data('tagid') + this._addTagFromID(tagId); + this._setAudibleStatus("Selected " + this.tagsByID[tagId].key); this._selectionReset(true, true); } else if (suggestionItem.data('freetext') && !suggestionItem.data('tagid')) { - this._addFreeText(suggestionItem.data('freetext')); - this._setAudibleStatus("Added " + this.tagsByID[suggestionItem.data('tagid')].key); + var freetext = suggestionItem.data('freetext'); + this._addFreeText(freetext); + this._setAudibleStatus("Added " + freetext); this._selectionReset(true, true); } else {