AngularJS - NVDA 屏幕 reader 未找到子元素的名称

AngularJS - NVDA screen reader not finding names of child elements

在这里为准系统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 在键入列表时说 "blank"。如果在我们用来创建这个 HTML 的指令中,我将 role="presentation" 添加到 <ul>,那么 NVDA 将在下拉列表打开后立即列出整个选项列表,但是不是针对每个箭头键击键单独进行(并且在按下 Escape 使其停止说话后,键入选项再次显示 "blank")。

我一直认为 listboxoption 角色在正确的位置,但结构中是否有其他东西阻止屏幕 reader 正确找到值?

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

虽然没有看到生成的 HTML 而不是 Angular 源,但可能有一些原因可能导致此问题。

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

第二个最可能的罪魁祸首是 role="option" 应该在 role="listbox" 上的 直系子代 上。将其移动到您的 <li> 并使它们 select 可以使用 tabindex="-1"(请参阅下面 tabindex="0" 的要点)。 (事实上​​,为什么不简单地删除周围的 <div> 并将所有 angular 指令直接应用于 <li>)。

第三个最可能的罪魁祸首是不需要 aria-label 并且实际上可能会造成干扰,屏幕 reader 将在没有此的情况下阅读您 <span> 中的文本。黄金法则 - 不要使用 aria 除非你不能用另一种方式描述信息。

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

您还应该将 aria-multiselectable="true" 添加到 <ul> 以表明它是一个多 select。

删除 title 属性,此处不会添加任何有用的内容。

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

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

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

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

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

如您所见(我还没有说完)有很多事情需要考虑。

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