diff --git a/src/jquery.tagger.css b/src/jquery.tagger.css index 9023694..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; @@ -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..15b7253 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,13 +227,14 @@ .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')) { 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')); } @@ -254,14 +258,28 @@ this.taggerWidget.css('height', this.options.fieldHeight); } + this.taggerSelectedTags = $('
') + .attr('class', 'tagger-selected-tags') + .attr('id', this.selectedTagsID) + .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'); this.taggerButtonsPanel.appendTo(this.taggerWidget); @@ -290,8 +308,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); @@ -304,14 +320,27 @@ // 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(); // Select the widget itself again 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 @@ -323,6 +352,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 = $('
') @@ -553,7 +590,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); } @@ -592,19 +629,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 * @@ -707,7 +753,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 +798,7 @@ } } // Load filtered results into the suggestion list - this._loadSuggestions(filteredResults, false); + this._loadSuggestions(filteredResults, false, true); this.loadedFiltered = true; }, @@ -796,7 +842,7 @@ } }); self.tagsByID = data; - self._loadSuggestions(data, false); + self._loadSuggestions(data, false, true); self.loadedFiltered = true; self._showSuggestions(false); }, @@ -933,9 +979,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 +1039,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 +1051,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 +1067,11 @@ .addClass('missing') .text(this.options.emptyListText) .appendTo(this.taggerSuggestionsList); + + if (setAudibleStatus) { + this._setAudibleStatus(this.options.emptyListText); + audibleStatusSet = true; + } } if (suggestableTags.limited) { @@ -1023,6 +1082,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'); } }, @@ -1095,11 +1164,15 @@ // Handle suggestion adding var suggestionItem = $(event.target).closest('li'); if (suggestionItem.data('tagid') && !suggestionItem.data('freetext')) { - this._addTagFromID(suggestionItem.data('tagid')); + 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')); + var freetext = suggestionItem.data('freetext'); + this._addFreeText(freetext); + this._setAudibleStatus("Added " + freetext); this._selectionReset(true, true); } else { @@ -1234,7 +1307,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 +1329,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 +1341,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 +1381,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 +1431,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 +1489,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 +1549,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 +1602,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 +1701,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