AngularJS - NVDA屏幕阅读器找不到子元素的名称

AngularJS - NVDA screen reader not finding names of child elements

提问人:Leland 提问时间:3/4/2020 最后编辑:Leland 更新时间:3/4/2020 访问量:1838

问:

为这里的简陋 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 的指令中,我添加到 ,那么 NVDA 将在下拉列表打开后立即背诵整个选项列表,但不会针对每个箭头键击键单独背诵(并且在点击 Escape 使其停止说话后,键入选项再次显示“空白”)。role="presentation"<ul>

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

AngularJS 可访问 性 jaws-screen-reader nvda

评论


答:

1赞 GrahamTheDev 3/4/2020 #1

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

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

最有可能的罪魁祸首是你的锚点无效。不能有空白的 href () 才能使其有效。查看您的源代码,您不能删除它并调整您的 CSS 或将其更改为 ?href=""<div>

第二个最有可能的罪魁祸首是,应该在直接的孩子身上。将其移动到您的 s 并使它们可以选择(见下文)代替。(事实上,为什么不简单地删除周围环境并将所有角度指令直接应用于)。role="option"role="listbox"<li>tabindex="-1"tabindex="0"<div><li>

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

您还需要向每个项目添加(或 false)以指示是否选择了某个项目。aria-selected="true"<li role="option">

此外,您应该添加到 以表明它是多选。aria-multiselectable="true"<ul>

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

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

小心 - 我看不出这是否适用于所有内容,但实际上应该如此,并且您以编程方式管理焦点,否则用户可以按 Tab 键切换到他们不应该使用的项目。 应该在主要的.tabindex="0"tabindex="-1"tabindex="0"<ul>

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

我在 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 使用复选框模式(在链接页面左侧的组织过滤器中)进行多选(使用过滤器 - 您可以考虑使用 100 个不同的选项,因为它们在本文中强调了一些关键问题)。

正如你所看到的(我还没有完成),有很多事情需要考虑。

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

评论

0赞 Leland 3/4/2020
谢谢,我会看看我能做些什么。我一直在尝试将屏幕阅读器功能硬塞到这种现有形式中,而整个元素中只有一个元素(是的,所有可选选项都是该单个元素中的一系列)这一事实增加了另一层难度。<li><ul><div><li>
0赞 GrahamTheDev 3/4/2020
哦,我误读了这意味着 100 个左右的列表项。在这种情况下,删除周围的列表项并将所有 s 转换为 - 这将解决几个问题。<div><li>
0赞 Leland 3/4/2020
是的,我认为这就是我和同事要做的事情。阅读您的建议有助于我们了解为什么现有结构不起作用。
0赞 GrahamTheDev 3/5/2020
好东西,因为你是 Stack Overflow 的新手,如果答案包含你需要的信息,你可以点击投票箭头下方的勾号来接受答案(你也可以使用箭头对答案进行投票)。任何问题只需在此处发表评论或提出新问题(如果它超过一行或两行回复)。
0赞 Leland 3/7/2020
我们要么修改原始指令,要么尝试新的结构(使用 s 和 s)并重新添加原始功能。原版有一些很酷的技巧,例如能够在提交表单之前取消选择选项(这是对数百万条记录的搜索,因此实时结果不会;)发生),只是无法完全访问。<select><option>