')
+ .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 = $('
')
@@ -458,6 +515,7 @@
}
}
this.canFireActions = true;
+ this._updateAccessibleText();
}
else {
throw 'Tagger widget only works on select elements';
@@ -553,7 +611,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 +650,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 +774,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 +819,7 @@
}
}
// Load filtered results into the suggestion list
- this._loadSuggestions(filteredResults, false);
+ this._loadSuggestions(filteredResults, false, true);
this.loadedFiltered = true;
},
@@ -796,7 +863,7 @@
}
});
self.tagsByID = data;
- self._loadSuggestions(data, false);
+ self._loadSuggestions(data, false, true);
self.loadedFiltered = true;
self._showSuggestions(false);
},
@@ -854,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)
@@ -933,9 +1051,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 +1111,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 +1123,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 +1139,11 @@
.addClass('missing')
.text(this.options.emptyListText)
.appendTo(this.taggerSuggestionsList);
+
+ if (setAudibleStatus) {
+ this._setAudibleStatus(this.options.emptyListText);
+ audibleStatusSet = true;
+ }
}
if (suggestableTags.limited) {
@@ -1023,6 +1154,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');
}
},
@@ -1068,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');
@@ -1095,11 +1245,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 +1388,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 +1410,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 +1422,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 +1462,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 +1512,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
@@ -1415,16 +1569,19 @@
// 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)
- .insertBefore(this.taggerInput);
+ .appendTo(this.taggerSelectedTags);
if (tagData.freetext) {
tag.addClass('freetext');
}
- var tagRemover = $('

');
+ var tagRemover = $('

');
// Reusable tag removal closure
var tagRemoveProcessing = function () {
@@ -1475,9 +1632,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 +1685,7 @@
}
}
else {
- tag = $('
').prependTo(this.taggerWidget);
+ tag = $('
').appendTo(this.taggerSelectedTags);
tag.text($('
').html(tagData.key).text());
if (this.singleValue) {
tag.addClass('tag-single');
@@ -1536,6 +1693,7 @@
}
this.tagCount++;
+ this._updateAccessibleText();
// Remove tag from tags object
this.tagsByID[tagID].suggestable = false;
@@ -1603,6 +1761,7 @@
// Remove tag div
tagElem.remove();
this.tagCount--;
+ this._updateAccessibleText();
// Deselect from hidden select
$('option[value="' + tagID + '"]', this.element).prop("selected", false);
@@ -1627,12 +1786,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