From 30532568bbdfb10b04c3641b6427f9db072a67bc Mon Sep 17 00:00:00 2001 From: Matt Eason Date: Wed, 1 May 2019 14:05:52 +0100 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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 { From 8bcd129e9669abe9f1310edb11f791c7cc5086e9 Mon Sep 17 00:00:00 2001 From: Tyler Amos Date: Tue, 17 Sep 2019 09:09:12 +0100 Subject: [PATCH 8/8] Further screen reader improvements --- src/jquery.tagger.js | 91 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/src/jquery.tagger.js b/src/jquery.tagger.js index 15b7253..08a028f 100644 --- a/src/jquery.tagger.js +++ b/src/jquery.tagger.js @@ -1,7 +1,7 @@ /* * jQuery UI Tagger * - * @version v0.8.2 (05/2019) + * @version v0.8.3 (09/2019) * * Copyright 2015, Fivium Ltd. * Released under the BSD 3-Clause license. @@ -14,6 +14,7 @@ * Nick Palmer * Ben Basson * Matt Eason + * Tyler Amos * * Maintainer: * Matt Eason - matt.eason@fivium.co.uk @@ -103,6 +104,7 @@ * @property {string} freeTextPrefix - Optional string to prefix all free text option values with (helpful to differentiate server-side) * @property {string} freeTextMessage - HTML string to show in the suggestions list containing the free text to hint that it can be added e.g. Add <em>%VALUE%</em> to list * @property {string} freeTextSuggest - Allow free text values in the select to show up in the suggestions list + * @property {string} ariaDescribedBy - Text to add to the aria-describedby attribute of the tagger */ options: { availableTags : null @@ -139,6 +141,7 @@ , freeTextPrefix : null , freeTextMessage : null , freeTextSuggest : false + , ariaDescribedBy : null }, keyCodes: { @@ -219,6 +222,8 @@ this.taggerID = 'tagger' + originalElementID; this.suggestionsListID = 'suggestions' + originalElementID; this.selectedTagsID = 'selectedTags' + originalElementID; + this.tagGuidanceID = 'tagGuidance' + originalElementID; + this.screenReaderDescID = 'audioGuidance' + originalElementID; // Construct tagger widget this.taggerWidget = $('
') @@ -229,7 +234,7 @@ .attr('aria-autocomplete', 'list') .attr('aria-haspopup', 'listbox') .attr('aria-owns', this.suggestionsListID) - .attr('aria-describedby', this.selectedTagsID) + .attr('aria-describedby', this.screenReaderDescID + ' ' + this.options.ariaDescribedBy) .insertAfter(this.element); if (this.element.attr('aria-label')) { @@ -258,11 +263,26 @@ this.taggerWidget.css('height', this.options.fieldHeight); } + //this will get updated by _updateAccessibleText() and will contain text like + //'2 items have been selected, X and Y' + this.taggerScreenReaderDescription = $('
') + .addClass('tagger-audible-status') + .attr('id', this.screenReaderDescID) + .appendTo(this.taggerWidget); + this.taggerSelectedTags = $('
') .attr('class', 'tagger-selected-tags') + .attr('role', 'list') .attr('id', this.selectedTagsID) .appendTo(this.taggerWidget); + //add guidance to remove a selected tag, making this once and then referencing it in each selected tag + this.tagRemoveGuidance = $('
') + .addClass('tagger-audible-status') + .attr('id', this.tagGuidanceID) + .text(', press the backspace or delete key to remove this item') + .appendTo(this.taggerWidget); + if (!this.readonly) { var ariaLabel = "Autocomplete filter"; @@ -495,6 +515,7 @@ } } this.canFireActions = true; + this._updateAccessibleText(); } else { throw 'Tagger widget only works on select elements'; @@ -900,6 +921,57 @@ } }, + + /** + * Updates the accessible text that summarises the tagger to screen readers + * @protected + */ + _updateAccessibleText: function () { + var lScreenReaderDesc; + //if its single select + if(this.singleValue) { + lScreenReaderDesc = "No selection"; + $('.tag', this.taggerWidget).each(function() { + lScreenReaderDesc = $(this).text() + " is selected"; + }); + this.taggerScreenReaderDescription.text(lScreenReaderDesc); + } else { + //if its a multi-select + if(this.tagCount === 0) { + this.taggerScreenReaderDescription.text("No items have been selected"); + } else { + lScreenReaderDesc = this.tagCount + " "; + + if (this.tagCount === 1) { + lScreenReaderDesc += "item has"; + } + else { + lScreenReaderDesc += "items have"; + } + lScreenReaderDesc += " been selected"; + + //if there are 3 or less, list them out + if (this.tagCount < 4) { + //locally defining this as 'this' refers to the loop item in the loop + var lTagCount = this.tagCount; + $('.tag', this.taggerWidget).each(function (i) { + //if its the last item (and its not the first) + if (i === lTagCount -1 && i != 0) { + lScreenReaderDesc += " and "; + } else { + lScreenReaderDesc += ", "; + } + lScreenReaderDesc += $(this).text(); + }); + } else { + //if there are 4 or more, just tell the user to tab over them + lScreenReaderDesc += ", use the tab key to access the list of selected items"; + } + this.taggerScreenReaderDescription.text(lScreenReaderDesc); + } + } + }, + /** * Return the main tagger input, if it's visible, or the widget itself if the input is not visible (e.g. an item has * been selected) @@ -1137,6 +1209,15 @@ // Indent suggestions suggestion.css('padding-left', (tag.level * this.options.indentMultiplier) + 'em'); } + + //expose level to screen readers + //add one to the level so that it starts from 1 + var lScreenReaderLevel = parseInt(tag.level) + 1; + $('
') + .addClass('tagger-audible-status') + .text('Level ' + lScreenReaderLevel) + .appendTo(suggestion); + if (!tag.suggestable) { // If it's not suggestable (already selected) then just grey it out, remove it from tabindex and unbind events suggestion.addClass('extra'); @@ -1164,7 +1245,7 @@ // Handle suggestion adding var suggestionItem = $(event.target).closest('li'); if (suggestionItem.data('tagid') && !suggestionItem.data('freetext')) { - var tagId = suggestionItem.data('tagid') + var tagId = suggestionItem.data('tagid'); this._addTagFromID(tagId); this._setAudibleStatus("Selected " + this.tagsByID[tagId].key); this._selectionReset(true, true); @@ -1488,8 +1569,10 @@ // Add the HTML to show the tag tag = $('
') .addClass('tag') + .attr("role", "listitem") .attr("tabindex", this.tabIndex) .attr("aria-label", tagData.key) + .attr("aria-describedby", this.tagGuidanceID) .text($('
').html(tagData.key).text()) .data("tagid", tagID) .appendTo(this.taggerSelectedTags); @@ -1610,6 +1693,7 @@ } this.tagCount++; + this._updateAccessibleText(); // Remove tag from tags object this.tagsByID[tagID].suggestable = false; @@ -1677,6 +1761,7 @@ // Remove tag div tagElem.remove(); this.tagCount--; + this._updateAccessibleText(); // Deselect from hidden select $('option[value="' + tagID + '"]', this.element).prop("selected", false);