Search code examples
angularjsaccessibilityjaws-screen-readernvda

AngularJS - NVDA screen reader not finding names of child elements


Apologies for the bare-bones HTML here...

I've got some AngularJS components that are rendering this HTML for a multiselectable dropdown:

<ul role="listbox">
    <li>
        <div ng-attr-id="ui-select-choices-row-{{ $select.generatedId }}-{{$index}}" class="ui-select-choices-row ng-scope" ng-class="{active: $select.isActive(this), disabled: $select.isDisabled(this)}" role="option" ng-repeat="opt in $select.items" ng-if="$select.open" ng-click="$select.select(opt,$select.skipFocusser,$event)" tabindex="0" id="ui-select-choices-row-0-1" style="">
            <a href="" class="ui-select-choices-row-inner" uis-transclude-append="">
                <span ng-class="{'strikethrough' : rendererInactive(opt)}" title="ALBANY" aria-label="ALBANY" class="ng-binding ng-scope">ALBANY</span>
            </a>
        </div>
        (a hundred or so more options in similar divs)
    </li>
</ul>

What we need is for screen reading software to speak aloud each option as it's highlighted via arrow key navigation. As it is now, NVDA says "blank" when keying through the list. If, in the directive we're using to create this HTML, I add role="presentation" to the <ul>, then NVDA will recite the entire list of options as soon as the dropdown opens, but not individually for each arrow key keystroke (and after hitting Escape to make it stop talking, keying through the options says "blank" again).

I keep thinking that the listbox and option roles are in the correct places, but is something else in the structure preventing the screen reader from finding the values correctly?


Solution

  • This answer got quite long, the first 3 points are most likely the problem, the rest are other considerations / observations

    There are a few things that are likely to cause this issue, although without seeing the generated HTML rather than the Angular Source there could be others.

    Most likely culprit is that your anchors are not valid. You cannot have a blank href (href="") for it to be valid. Looking at your source code could you not remove this and adjust your CSS or change it to a <div>?

    Second most likely culprit is that role="option" should be on the direct children on role="listbox". Move it to your <li>s and make them selectable with tabindex="-1" (see below point on tabindex="0") instead. (in fact why not simply remove the surrounding <div> and apply all of your angular directives to the <li> directly).

    Third most likely culprit is the fact that aria-label is not needed and may in fact be interfering, a screen reader will read the text within your <span> without this. Golden rule - do not use aria unless you can't portray the information another way.

    You also need to add aria-selected="true" (or false) to each <li role="option"> to indicate whether an item is selected or not.

    Also you should add aria-multiselectable="true" to the <ul> to indicate it is a multi select.

    While you are at it, remove the title attribute, it doesn't add anything useful here.

    aria-activedescendant="id" should be used to indicate which item is currently focused.

    Be careful with tabindex="0" - I can't see if this is applied to everything but really it should be tabindex="-1" and you programatically manage focus as otherwise users could tab to items that they aren't meant to. tabindex="0" should be on the main <ul>.

    Due to the complex nature of multi-selects you would be much better using a group of checkboxes as they provide a lot of the functionality for free, but that is just a suggestion.

    The following example I found on codepen.io covers 95% of everything if you use a checkbox instead and would be a good base for you to pick apart and adapt to your needs, as you can see checkboxes make life a lot easier as all the selected not selected functionality is built in.

    (function($){
    	'use strict';
    	
    	const DataStatePropertyName = 'multiselect';
    	const EventNamespace = '.multiselect';
    	const PluginName = 'MultiSelect';
    	
    	var old = $.fn[PluginName];
    	$.fn[PluginName] = plugin;
        $.fn[PluginName].Constructor = MultiSelect;
        $.fn[PluginName].noConflict = function () {
            $.fn[PluginName] = old;
            return this;
        };
    
        // Defaults
        $.fn[PluginName].defaults = {
            
        };
    	
    	// Static members
        $.fn[PluginName].EventNamespace = function () {
            return EventNamespace.replace(/^\./ig, '');
        };
        $.fn[PluginName].GetNamespacedEvents = function (eventsArray) {
            return getNamespacedEvents(eventsArray);
        };
    	
    	function getNamespacedEvents(eventsArray) {
            var event;
            var namespacedEvents = "";
            while (event = eventsArray.shift()) {
                namespacedEvents += event + EventNamespace + " ";
            }
            return namespacedEvents.replace(/\s+$/g, '');
        }
    	
    	function plugin(option) {
            this.each(function () {
                var $target = $(this);
                var multiSelect = $target.data(DataStatePropertyName);
                var options = (typeof option === typeof {} && option) || {};
    
                if (!multiSelect) {
                    $target.data(DataStatePropertyName, multiSelect = new MultiSelect(this, options));
                }
    
                if (typeof option === typeof "") {
                    if (!(option in multiSelect)) {
                        throw "MultiSelect does not contain a method named '" + option + "'";
                    }
                    return multiSelect[option]();
                }
            });
        }
    
        function MultiSelect(element, options) {
            this.$element = $(element);
            this.options = $.extend({}, $.fn[PluginName].defaults, options);
            this.destroyFns = [];
    		
    		this.$toggle = this.$element.children('.toggle');
    		this.$toggle.attr('id', this.$element.attr('id') + 'multi-select-label');
    		this.$backdrop = null;
    		this.$allToggle = null;
    
            init.apply(this);
        }
    	
    	MultiSelect.prototype.open = open;
    	MultiSelect.prototype.close = close;
    	
    	function init() {
    		this.$element
    		.addClass('multi-select')
    		.attr('tabindex', 0);
    		
            initAria.apply(this);
    		initEvents.apply(this);
    		updateLabel.apply(this);
    		injectToggleAll.apply(this);
    		
    		this.destroyFns.push(function() {
    			return '|'
    		});
        }
    	
    	function injectToggleAll() {
    		if(this.$allToggle && !this.$allToggle.parent()) {
    			this.$allToggle = null;
    		}
    		
    		this.$allToggle = $("<li><label><input type='checkbox'/>(all)</label><li>");
    		
    		this.$element
    		.children('ul:first')
    		.prepend(this.$allToggle);
    	}
    	
    	function initAria() {
    		this.$element
    		.attr('role', 'combobox')
    		.attr('aria-multiselect', true)
    		.attr('aria-expanded', false)
    		.attr('aria-haspopup', false)
    		.attr('aria-labeledby', this.$element.attr("aria-labeledby") + " " + this.$toggle.attr('id'));
    		
    		this.$toggle
    		.attr('aria-label', '');
    	}
    	
    	function initEvents() {
    		var that = this;
    		this.$element
    		.on(getNamespacedEvents(['click']), function($event) {	
    			if($event.target !== that.$toggle[0] && !that.$toggle.has($event.target).length) {
    				return;
    			}			
    
    			if($(this).hasClass('in')) {
    				that.close();
    			} else {
    				that.open();
    			}
    		})
    		.on(getNamespacedEvents(['keydown']), function($event) {
    			var next = false;
    			switch($event.keyCode) {
    				case 13: 
    					if($(this).hasClass('in')) {
    						that.close();
    					} else {
    						that.open();
    					}
    					break;
    				case 9:
    					if($event.target !== that.$element[0]	) {
    						$event.preventDefault();
    					}
    				case 27:
    					that.close();
    					break;
    				case 40:
    					next = true;
    				case 38:
    					var $items = $(this)
    					.children("ul:first")
    					.find(":input, button, a");
    
    					var foundAt = $.inArray(document.activeElement, $items);				
    					if(next && ++foundAt === $items.length) {
    						foundAt = 0;
    					} else if(!next && --foundAt < 0) {
    						foundAt = $items.length - 1;
    					}
    
    					$($items[foundAt])
    					.trigger('focus');
    			}
    		})
    		.on(getNamespacedEvents(['focus']), 'a, button, :input', function() {
    			$(this)
    			.parents('li:last')
    			.addClass('focused');
    		})
    		.on(getNamespacedEvents(['blur']), 'a, button, :input', function() {
    			$(this)
    			.parents('li:last')
    			.removeClass('focused');
    		})
    		.on(getNamespacedEvents(['change']), ':checkbox', function() {
    			if(that.$allToggle && $(this).is(that.$allToggle.find(':checkbox'))) {
    				var allChecked = that.$allToggle
    				.find(':checkbox')
    				.prop("checked");
    				
    				that.$element
    				.find(':checkbox')
    				.not(that.$allToggle.find(":checkbox"))
    				.each(function(){
    					$(this).prop("checked", allChecked);
    					$(this)
    					.parents('li:last')
    					.toggleClass('selected', $(this).prop('checked'));
    				});
    				
    				updateLabel.apply(that);
    				return;
    			}
    			
    			$(this)
    			.parents('li:last')
    			.toggleClass('selected', $(this).prop('checked'));
    			
    			var checkboxes = that.$element
    			.find(":checkbox")
    			.not(that.$allToggle.find(":checkbox"))
    			.filter(":checked");
    			
    			that.$allToggle.find(":checkbox").prop("checked", checkboxes.length === checkboxes.end().length);
    
    			updateLabel.apply(that);
    		})
    		.on(getNamespacedEvents(['mouseover']), 'ul', function() {
    			$(this)
    			.children(".focused")
    			.removeClass("focused");
    		});
    	}
    	
    	function updateLabel() {
    		var pluralize = function(wordSingular, count) {
    			if(count !== 1) {
    				switch(true) {
    					case /y$/.test(wordSingular):
    						wordSingular = wordSingular.replace(/y$/, "ies");
    					default:
    						wordSingular = wordSingular + "s";
    				}
    			}			
    			return wordSingular;
    		}
    		
    		var $checkboxes = this.$element
    		.find('ul :checkbox');
    		
    		var allCount = $checkboxes.length;
    		var checkedCount = $checkboxes.filter(":checked").length
    		var label = checkedCount + " " + pluralize("item", checkedCount) + " selected";
    		
    		this.$toggle
    		.children("label")
    		.text(checkedCount ? (checkedCount === allCount ? '(all)' : label) : 'Select a value');
    		
    		this.$element
    		.children('ul')
    		.attr("aria-label", label + " of " + allCount + " " + pluralize("item", allCount));
    	}
    	
    	function ensureFocus() {
    		this.$element
    		.children("ul:first")
    		.find(":input, button, a")
    		.first()
    		.trigger('focus')
    		.end()
    		.end()
    		.find(":checked")
    		.first()
    		.trigger('focus');
    	}
    	
    	function addBackdrop() {
    		if(this.$backdrop) {
    			return;
    		}
    		
    		var that = this;
    		this.$backdrop = $("<div class='multi-select-backdrop'/>");
    		this.$element.append(this.$backdrop);
    		
    		this.$backdrop
    		.on('click', function() {
    			$(this)
    			.off('click')
    			.remove();
    			
    			that.$backdrop = null;			
    			that.close();
    		});
    	}
    	
    	function open() {
    		if(this.$element.hasClass('in')) {
    			return;
    		}
    
    		this.$element
    		.addClass('in');
    		
    		this.$element
    		.attr('aria-expanded', true)
    		.attr('aria-haspopup', true);
    
    		addBackdrop.apply(this);
    		//ensureFocus.apply(this);
    	}
    	
    	function close() {
    		this.$element
    		.removeClass('in')
    		.trigger('focus');
    		
    		this.$element
    		.attr('aria-expanded', false)
    		.attr('aria-haspopup', false);
    
    		if(this.$backdrop) {
    			this.$backdrop.trigger('click');
    		}
    	}	
    })(jQuery);
    
    $(document).ready(function(){
    	$('#multi-select-plugin')
    	.MultiSelect();
    });
    * {
      box-sizing: border-box;
    }
    
    .multi-select, .multi-select-plugin {
      display: inline-block;
      position: relative;
    }
    .multi-select > span, .multi-select-plugin > span {
      border: none;
      background: none;
      position: relative;
      padding: .25em .5em;
      padding-right: 1.5em;
      display: block;
      border: solid 1px #000;
      cursor: default;
    }
    .multi-select > span > .chevron, .multi-select-plugin > span > .chevron {
      display: inline-block;
      transform: rotate(-90deg) scale(1, 2) translate(-50%, 0);
      font-weight: bold;
      font-size: .75em;
      position: absolute;
      top: .2em;
      right: .75em;
    }
    .multi-select > ul, .multi-select-plugin > ul {
      position: absolute;
      list-style: none;
      padding: 0;
      margin: 0;
      left: 0;
      top: 100%;
      min-width: 100%;
      z-index: 1000;
      background: #fff;
      border: 1px solid rgba(0, 0, 0, 0.15);
      box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
      display: none;
      max-height: 320px;
      overflow-x: hidden;
      overflow-y: auto;
    }
    .multi-select > ul > li, .multi-select-plugin > ul > li {
      white-space: nowrap;
    }
    .multi-select > ul > li.selected > label, .multi-select-plugin > ul > li.selected > label {
      background-color: LightBlue;
    }
    .multi-select > ul > li.focused > label, .multi-select-plugin > ul > li.focused > label {
      background-color: DodgerBlue;
    }
    .multi-select > ul > li > label, .multi-select-plugin > ul > li > label {
      padding: .25em .5em;
      display: block;
    }
    .multi-select > ul > li > label:focus, .multi-select > ul > li > label:hover, .multi-select-plugin > ul > li > label:focus, .multi-select-plugin > ul > li > label:hover {
      background-color: DodgerBlue;
    }
    .multi-select.in > ul, .multi-select-plugin.in > ul {
      display: block;
    }
    .multi-select-backdrop, .multi-select-plugin-backdrop {
      position: fixed;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      z-index: 900;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <label id="multi-select-plugin-label" style="display:block;">Multi Select</label>
    <div id="multi-select-plugin" aria-labeledby="multi-select-plugin-label">
    	<span class="toggle">
    		<label>Select a value</label>
    		<span class="chevron">&lt;</span>
    	</span>
    	<ul>
    		<li>
    			<label>
    				<input type="checkbox" name="selected" value="0"/>
    				Item 1
    			</label>
    		</li>
    		<li>
    			<label>
    				<input type="checkbox" name="selected" value="1"/>
    				Item 2
    			</label>
    		</li>
    		<li>
    			<label>
    				<input type="checkbox" name="selected" value="2"/>
    				Item 3
    			</label>
    		</li>
    		<li>
    			<label>
    				<input type="checkbox" name="selected" value="3"/>
    				Item 4
    			</label>
    		</li>
    	</ul>
    </div>

    Also you will see that gov.uk uses a checkbox pattern (within the organisation filter on the left on the linked page) for their multi-selects (with a filter - something you may consider with 100 different options as they have highlighted some key concerns in this article).

    As you can see (and I wasn't finished) there is a lot to consider.

    Hope I haven't scared you too much and the first few points solve the issue you originally asked about!