【问题标题】:AngularJS - NVDA screen reader not finding names of child elementsAngularJS - NVDA 屏幕阅读器找不到子元素的名称
【发布时间】:2026-02-07 04:50:01
【问题描述】:

对这里的基本 HTML 表示歉意...

我有一些 AngularJS 组件正在渲染这个 HTML 以获得多选下拉列表:

<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>

我们需要让屏幕阅读软件大声朗读每个选项,因为它通过箭头键导航突出显示。就像现在一样,NVDA 在键入列表时会说“空白”。如果在我们用于创建此 HTML 的指令中,我将 role="presentation" 添加到 &lt;ul&gt;,则 NVDA 将在下拉列表打开后立即背诵整个选项列表,但不会单独为每个箭头键击键 (并在点击 Escape 使其停止说话后,通过选项键再次显示“空白”)。

我一直认为listboxoption 角色位于正确的位置,但结构中的其他内容是否会阻止屏幕阅读器正确查找值?

【问题讨论】:

    标签: angularjs accessibility jaws-screen-reader nvda


    【解决方案1】:

    这个答案很长,前3点很可能是问题所在,其余的都是其他考虑/观察

    有几件事可能会导致此问题,尽管没有看到生成的 HTML 而不是 Angular 源,但可能还有其他原因。

    最有可能的罪魁祸首是您的锚点无效。您不能有一个空白的 href (href="") 以使其有效。查看您的源代码,您是否可以不删除它并调整您的 CSS 或将其更改为 &lt;div&gt;

    第二个最可能的罪魁祸首是role="option" 应该在role="listbox" 上的直系子代上。将其移至您的&lt;li&gt;s 并使用tabindex="-1" 将其选中(请参阅tabindex="0" 的以下点)。 (事实上​​,为什么不简单地删除周围的&lt;div&gt; 并将所有角度指令直接应用于&lt;li&gt;)。

    第三个最可能的罪魁祸首是aria-label 不需要并且实际上可能会干扰,屏幕阅读器将在没有这个的情况下阅读您的&lt;span&gt; 中的文本。黄金法则 - 不要使用aria,除非您无法以其他方式描述信息。

    您还需要在每个&lt;li role="option"&gt; 中添加aria-selected="true"(或false)以指示是否选择了某个项目。

    您还应该将aria-multiselectable="true" 添加到&lt;ul&gt; 以表明它是多选。

    当您使用它时,删除title 属性,它不会在这里添加任何有用的东西。

    aria-activedescendant="id" 应该用于指示当前关注的项目。

    小心tabindex="0" - 我看不出这是否适用于所有内容,但实际上它应该是tabindex="-1" 并且您以编程方式管理焦点,否则用户可能会选择他们不希望的项目。 tabindex="0" 应该在主 &lt;ul&gt; 上。

    由于多选的复杂性,您最好使用一组复选框,因为它们免费提供许多功能,但这只是一个建议。

    如果您使用复选框,则以下example I found on codepen.io 涵盖了所有内容的 95%,这将是您挑选并适应您的需求的良好基础,因为您可以看到复选框使所有选中的生活变得更加轻松内置未选择的功能。

    (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>

    您还将看到 gov.uk uses a checkbox pattern(在链接页面左侧的组织过滤器中)用于他们的多选(带有过滤器 - 您可以考虑使用 100 个不同的选项,因为他们有 highlighted some key concerns in this article)。

    如您所见(我还没有完成),有很多需要考虑的地方。

    希望我没有吓到你,前几点解决了你最初问的问题!

    【讨论】:

    • 谢谢,我看看我能做什么。我一直在尝试将屏幕阅读器功能硬塞到这个现有的表单中,而事实上整个 &lt;li&gt; 元素只有一个 &lt;ul&gt; (是的,所有可选选项都是其中的一系列 &lt;div&gt;s单个&lt;li&gt; 元素)增加了另一层难度。
    • 哦,我误读了这意味着 100 个左右的列表项。在这种情况下,删除周围的列表项并将所有 &lt;div&gt;s 转换为 &lt;li&gt; - 这将解决几个问题。
    • 是的,我想这就是我和我的同事要做的事情。阅读您的建议有助于我们了解现有结构为何不起作用。
    • 好东西,因为您是堆栈溢出的新手,如果答案包含您需要的信息,您可以单击投票箭头下方的勾号以接受答案(您也可以向上或向下投票使用箭头)。如有任何问题,请在此处发表评论或提出新问题(如果回答不止一两行)。
    • 我们要么修改原始指令,要么尝试新结构(使用&lt;select&gt;s 和&lt;option&gt;s)并重新添加原始指令的功能。原版有一些很酷的技巧,比如在提交表单之前取消选择选项的能力(它是搜索一百万条记录,所以不会发生实时结果;)),只是不能完全访问。
    最近更新 更多