构建丰富的、可访问的JavaScript界面的教程

111 阅读16分钟

你是个JavaScript奇才,几乎可以建立任何东西--但每个人都能使用它吗?在这篇文章中,我们将探讨一些方法来制作丰富的、可访问的JavaScript界面。我们将通过建立一个简单的数独谜题来说明我们的策略,这个谜题对每个人都适用,甚至对那些盲人或弱视者也适用。

目录

从一开始就为无障碍性做计划

在一个界面上加装无障碍设施可能是困难和耗时的;如果你从一开始就对无障碍设施进行规划,你总是会得到更好的结果。你可能听说过POUR这个缩写,它是规划无障碍性的一个好方法。

POUR代表的是:

  • 可感知的。所有用户都应该能够感知到界面。视觉元素应该有相当的文本替代物,颜色方案应该对低色觉的人友好。
  • 可操作性。所有用户都应该能够操作界面。确保所有的东西都可以在没有鼠标的情况下操作,并且触摸或点击的目标对所有的用户来说都足够大。
  • 可理解的。界面应尽可能的简单和直观
  • 健全。界面应该能够在当前所有的浏览器和辅助技术中工作。这可能意味着只使用那些成熟的技术,而不是那些提供 "最新和最伟大 "功能的技术。

对于像数独这样基于网格的游戏,在无障碍性方面最大的挑战是使界面可以理解。数独游戏要求玩家知道哪些数字被输入到网格的每个单元中。为了做到无障碍,数独游戏需要向那些可能无法看到游戏板的用户传达这一信息。

一个有81个单元格的经典九位数独谜题对于大多数人来说太大了,如果棋盘不可见的话,是无法解决的。因此,我的第一个决定是简化事情,做一个有16个单元格的四位数数独。

接下来,需要有一个一致的方式来描述游戏板--一个符号系统。我的第一直觉是使用一个简单的XY坐标系统,像国际象棋那样。然而,数独网格比国际象棋棋盘更复杂,它实际上是一个大网格中的一组网格。

下面是我确定的符号系统:

Notation System for Accessible Sudoku

无障碍数独的记号系统。

游戏板由四个方格组成,以二乘二的网格排列。

最上面一排的方格被称为A方和B方,最下面一排的方格被称为C方和D方。

每个正方形包含四个以二乘二网格排列的单元格。每个正方形最上面一行的单元格被指定为单元格a和单元格b,最下面一行的单元格被指定为单元格c和单元格d。

这个符号可以描述棋盘上的任何单元格,并传达了玩家解谜所需的位置信息。因此,如果我们加入机制来宣布屏幕阅读器用户在棋盘上的当前位置,并列出相应的行、列和方格中的数字,游戏应该是可以理解的。

考虑到POUR原则的构建

现在是时候开始构建了,要牢记POUR原则。构建无障碍界面的一个关键策略是避免重蹈覆辙。如果有一个历史悠久的标准HTML控件可以用来做某件事,那么就按照它的规格来使用它,并应用CSS来创造任何需要的视觉效果。

这个微型数独由16个排列在网格中的盒子组成,每个盒子可以容纳一个数字。因此,我使用了16个<input type="number> 元素。这些元素简直就是为这项工作而设计的,另外它们还有一个readonly 属性,用来标记固定值的单元格,这些单元格需要包括在内,以使数独可以解开。

我使用<div> 标签将单元格分组为方块,并使用CSS网格布局来组织事情。使用CSS网格布局而不是HTML<table> ,使我能够根据我的符号系统(Aa、Ab、Ac、Ad、Ba、Bb等)在代码中排列单元格。一个页面的标签顺序与它的底层结构相协调,所以当键盘用户使用标签键从一个<input> ,这将有助于保持界面的可理解。

<div class="board">
  <div class="square">
    <input type="number" min="0" max="4" length="1" value="">
    <input type="number" min="0" max="4" value="2" readonly>
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="">
  </div>
  <div class="square">
    <input type="number" min="0" max="4" value="3" readonly>
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="1" readonly>
  </div>
  <div class="square">
    <input type="number" min="0" max="4" value="2" readonly>
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="4" readonly>
  </div>
  <div class="square">
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="">
    <input type="number" min="0" max="4" value="1" readonly>
    <input type="number" min="0" max="4" value="">
  </div>
</div>
*, *:before, *:after {
  box-sizing: border-box;
}
body {
  font-size: 1.25rem;
  line-height: 1.35;
}
.board {
  width: 40vh;
  max-width: 90vw;
  height: 40vh;
  max-height: 90vw;
  padding: 0;
  border: 2px solid black;
  display: grid;
  grid-template: repeat(2,1fr) / repeat(2,1fr);
}
.square {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 2px solid black;
  display: grid;
  grid-template: repeat(2,1fr) / repeat(2,1fr);
}
.square input {
  display: block;
  color: black;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  text-align: center;
  font-size: 7vh;
  border: 1px solid black;
  -moz-appearance: textfield;
}
/* Hide HTML5 Up and Down arrows. */
.square input::-webkit-outer-spin-button, .square input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

选择颜色和大小

网页界面在无障碍性方面面临的一些常见问题是文字太小,用户界面的颜色没有提供足够的对比度,不容易被看到。

在尺寸方面,一个好的经验法则是,文本显示的有效字体大小不应小于16个CSS像素(这与设备像素不同)。对于数独游戏,我采用了最小的20像素(指定为1.25rem ,其中1rem 为16像素),并且还将标准line-height 增加到1.35,以使事物的间距更大,更容易阅读。

我使用vh 单位来指定游戏板的整体尺寸(1vh 是视口高度的1%),以尝试使其尽可能大,同时也保持游戏控制在同一视口内可见。

游戏主要使用黑色和白色,以最大限度地提高对比度。然而,我还需要显示哪些单元格是只读的,我想用颜色来显示键盘焦点目前在游戏板中的位置,(可选择)突出正确和错误的单元格。

这带来了另一个常见的问题--处理低色觉。用绿色来突出正确的单元格,用红色来突出不正确的单元格,这似乎是一种本能,但对于大多数低色觉的人来说,红色和绿色是一个非常糟糕的选择。

相反,我选择用天蓝色来指定正确的细胞,用红紫色来指定错误的细胞,用黄色来突出位置。确切的颜色色调是从Wong调色板上选择的。对于只读 (readonly) 单元,我使用了深灰色背景上的白色文字。所有这些颜色组合也提供了足够的对比度,这是用WebAIM对比度检查器测量的。

.correct {
  background-color: #56B4E9;
}
.incorrect {
  background-color: #CC79A7;
}
.square input:focus {
  background-color: #F0E442;
}
.square input:read-only {
  background-color: #333;
  color: white;
}

纳入WAI-ARIA的更多帮助

它可能还没有发挥作用,但我们现在有了一个看起来像微型数独游戏的东西。使用鼠标,我们可以很容易地点击在网格的每个单元格中输入数字,而且只使用键盘也可以。

我们仍然需要能够看到棋盘以浏览它,这对视力有限或没有视力的人来说是没有用的。幸运的是,网络无障碍倡议--可访问的富互联网应用(WAI-ARIA)可以帮助加入更多的援助。

[aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)属性用于为表单元素等添加额外的文本,当用户关注这些元素时,屏幕阅读器软件会对其进行朗读。我已经用这个属性为每个单元格添加了记号。

<div class="board">
    <div aria-label="Square A" class="square">
      <input aria-label="Square A, Cell A." type="number" min="0" max="4" length="1" value="">
      <input aria-label="Square A, Cell B. The value of this cell is fixed." type="number" min="0" max="4" value="2" readonly>
      <input aria-label="Square A, Cell C." type="number" min="0" max="4" value="">
      <input aria-label="Square A, Cell D." type="number" min="0" max="4" value="">
    </div>
    ...
</div>

我不需要将单元格的值添加到aria-label ,因为该值是默认读出的。但是,我碰巧知道,当<input> 元素是只读的时候,一些屏幕阅读器并不通知用户,所以我把这个信息包括进去。

添加互动性

现在是时候让游戏真正做一些事情了。

我们希望能够:

  • 检查游戏板是否已正确完成
  • 向难以完成谜题的玩家显示完整的解决方案
  • 从头开始重新启动游戏
  • 让玩家可以选择高亮显示哪些单元格是正确的,哪些是错误的

为了实现这些目标,我们需要将每个单元格的正确答案存储在某个地方,并在游戏板下方添加控件来触发每个动作。

我们有几种方法可以存储正确答案。我选择了为每个单元格添加一个data 属性,就像这样:

<input aria-label="Square A, Cell A." data-correct="1" type="number" min="0" max="4" length="1" value="">

游戏控制需要是可访问的,所以我将应用与之前相同的设计原则,使用<button><input type="checkbox"> 元素形式的库存HTML。

每个元素都需要一个独特的id ,这样我就可以将事件监听器附加到该元素上。我将使用标准的<label> 元素来确保高亮切换复选框可以被访问。我还将添加一个空的<div> ,我可以用它来向玩家显示公告。

<div class="controls">
    <label><input id="highlight" type="checkbox">&nbsp;HIGHLIGHT</label>
    <button id="check">CHECK YOUR ANSWER</button>
    <button id="show">SHOW SOLUTION</button>
    <button id="restart">RESTART</button>
</div>
<div id="announcement-all-users"></div>

四个简单的JavaScript函数是使游戏在基本水平上工作的全部需要。

let lang = "en";
const messages = {
  en: {
    boardCorrect: "Congratulations, you completed the Sudoku correctly!",
    boardIncorrect: "Hmmm... Not quite correct! Try again. Check 'HIGHLIGHT' to see where you went wrong."
  },
  announcementAllUsersDisplayed: false
}

function highlightCells(e){
  document.querySelectorAll('.square input:read-write').forEach( (cell) => { // toggle correct and incorrect classes for each non read-only cell
    if(document.querySelector('#highlight').checked){
      if(cell.value === cell.dataset.correct){
        cell.classList.add("correct");
        cell.classList.remove("incorrect");
      } else {
        cell.classList.add("incorrect");
        cell.classList.remove("correct");
      }
    } else {
      cell.classList.remove("correct","incorrect");
    }  
  } );
}
document.querySelector('#highlight').addEventListener('change', highlightCells );

function checkAnswer(e) {
  let allCorrect = true;
  document.querySelectorAll('.square input').forEach( (cell) => { // check each cell to see if it is correct
    if( cell.value !== cell.dataset.correct){
      allCorrect = false;
    }  
  } );
  // make general announcement of success/failure
  if(allCorrect){ 
    document.querySelector('#announcement-all-users').innerHTML = messages[lang].boardCorrect;
  } else {
    document.querySelector('#announcement-all-users').innerHTML = messages[lang].boardIncorrect;    
  }
  messages.announcementAllUsersDisplayed = true;  
}
document.querySelector('#check').addEventListener('click', checkAnswer );

function showSolution(e){
  if(messages.announcementAllUsersDisplayed){// remove any existing general annoucements
    document.querySelector('#announcement-all-users').innerHTML = "";
    messages.announcementAllUsersDisplayed = false;
  }
  document.querySelectorAll('.square input').forEach( (cell) => { // set the value of each cell to the correct value
    cell.value = cell.dataset.correct;
  } );  
}
document.querySelector('#show').addEventListener('click', showSolution );

function restartGame(e){
  if(messages.announcementAllUsersDisplayed){ // remove any existing general annoucements
    document.querySelector('#announcement-all-users').innerHTML = "";
    messages.announcementAllUsersDisplayed = false;
  }
  document.querySelectorAll('.square input:read-write').forEach( (cell) => { // reset each non read-only cell to be empty
    if(!cell.classList.contains("readonly")){
      cell.value = "";
    }
  } );  
}
document.querySelector('#restart').addEventListener('click', restartGame );

我们现在有了一个可以工作的数独游戏,它是可感知的、可操作的、健壮的(在CodePen上查看),但我们还有一些工作要做,以使它完全可被理解。

另外,还可以做一些更新,使游戏更容易操作。例如,highlightCells 功能只在视觉层面上发挥作用。为了对屏幕阅读器用户有用,我们需要把它扩展一下。游戏板的每个<input> 元素已经有一个aria-label 属性,所以我们可以操作它,在高亮复选框被拨动时增加或删除正确或错误的信息。

let lang = "en";
const messages = {
  en: {
    cellCorrect: "This cell is correct",
    cellIncorrect: "This cell is incorrect",
    boardCorrect: "Congratulations, you completed the Sudoku correctly!",
    boardIncorrect: "Hmmm... Not quite correct! Try again. Check 'HIGHLIGHT' to see where you went wrong."
  },
  announcementAllUsersDisplayed: false
}

function highlightCells(e){
  if(document.querySelector('#highlight').checked){
    document.querySelector('#highlight-help').classList.remove('v-hidden');
  } else {
    document.querySelector('#highlight-help').classList.add('v-hidden');    
  }
  document.querySelectorAll('.square input:read-write').forEach( (cell) => {
    let ariaLabelSplit = cell.ariaLabel.split('.');
    if(document.querySelector('#highlight').checked){
      if(cell.value === cell.dataset.correct){
        cell.classList.add("correct");
        cell.classList.remove("incorrect");
        cell.ariaLabel = ariaLabelSplit[0] + ". " + messages[lang].cellCorrect;
      } else {
        cell.classList.add("incorrect");
        cell.classList.remove("correct");
        cell.ariaLabel = ariaLabelSplit[0] + ". " + messages[lang].cellIncorrect;
      }
    } else {
      cell.classList.remove("correct","incorrect");
      cell.ariaLabel = ariaLabelSplit[0];
    }  
  } );
}
document.querySelector('#highlight').addEventListener('change', highlightCells ); 

现在,高亮功能应该只需最小的调整就能为大家工作。不过,仍然缺少一块大的拼图(如果你能原谅我的双关语的话)。

当每一行、每一列和每一个方块都精确地包含一次1到4的数字时,数独游戏就解决了。因此,屏幕阅读器用户需要一个快速而简单的非视觉方法来找出特定的行、列或方块中的数字。在这个项目中,我们第一次需要为这个用户群在游戏中建立额外的功能。

我决定通过在游戏中添加一个'read' ,由键盘快捷键触发的功能来解决这个问题。这就是 [aria-live](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live)属性的作用。如果我们把这个属性添加到一个元素(例如,一个简单的<div> ),那么当这个元素的内容发生变化时,就会被屏幕阅读器软件读出来。

这个功能可以通过用JavaScript改变元素的内容来向屏幕阅读器用户发布公告。我们将为现有的一般公告元素添加一个aria-live 属性,并为专为屏幕阅读器用户准备的消息添加另一个属性。这最后一条信息可以在游戏的最终版本中被放置在屏幕之外:

<div id="announcement-sr-only" aria-live="polite"></div>
<div id="announcement-all-users" aria-live="polite"></div>

"polite" 这个值意味着,当现场元素的内容发生变化时,屏幕阅读器将在阅读公告之前完成它目前正在读出的内容。这对数独来说很有效,但如果我们需要提醒用户一些更紧迫的事情,我们可以设置aria-live="assertive" ,这将导致我们的公告被直接读出。

下面是阅读功能对我们的数独游戏的作用:

  • 当用户的键盘焦点在游戏的一个单元格中时,他们按R(读),然后是R(行)、C(列)或S(方)。
  • 当前行、列或方格的内容摘要将显示在announcement-sr-only 元素中。

键盘快捷键对于屏幕阅读器来说可能是一个雷区,因为许多按键已经分配了快捷键,而且这些分配可能会根据屏幕阅读器的模式而改变。因此,要少用键盘快捷键,只在需要的地方将监听器附在元素上,并做大量的测试

为了让阅读功能发挥作用,我添加了一个id 属性来帮助我迭代单元格,一个data-id 元素用于迭代方格,并为readonly 单元格添加了一个额外的类,这样我就可以轻松地针对这些单元格。

<div class="board">
    <div data-id="A" class="square">
      <input id="11" data-id="A" aria-label="Square A, Cell A." data-correct="1" type="number" min="0" max="4" length="1" value="">
      <input id="12" data-id="B" aria-label="Square A, Cell B. The value of this cell is fixed." data-correct="2" type="number" min="0" max="4" value="2" class="readonly">
      <input id="21" data-id="C" aria-label="Square A, Cell C." data-correct="4" type="number" min="0" max="4" value="">
      <input id="22" data-id="D" aria-label="Square A, Cell D." data-correct="3" type="number" min="0" max="4" value="">
    </div>
    <div data-id="B" class="square">
      <input id="13" data-id="A" aria-label="Square B, Cell A. The value of this cell is fixed." data-correct="3" type="number" min="0" max="4" value="3" class="readonly">
      <input id="14" data-id="B" aria-label="Square B, Cell B." data-correct="4" type="number" min="0" max="4" value="">
      <input id="23" data-id="C" aria-label="Square B, Cell C." data-correct="2" type="number" min="0" max="4" value="">
      <input id="24" data-id="D" aria-label="Square B, Cell D. The value of this cell is fixed." data-correct="1" type="number" min="0" max="4" value="1" class="readonly">
    </div>
    ...
</div>

下面的代码展示了我是如何设置读取行的交互性的。读取列和方格的过程也类似:

const size = 4;
let lang = "en";
const messages = {
  en: {
    cellValue: "The value of this cell is ",
    cellEmpty: "This cell is empty.",
    cellEmptyShort: "Empty",
    cellCorrect: "This cell is correct",
    cellCorrectShort: "correct",
    cellIncorrect: "This cell is incorrect",
    cellIncorrectShort: "incorrect",
    cellFixedShort: "fixed value",
    boardCorrect: "Congratulations, you completed the Sudoku correctly!",
    boardIncorrect: "Hmmm... Not quite correct! Try again. Check 'HIGHLIGHT' to see where you went wrong.",
    reading: "Reading",
    currentRow: "current row",
    currentColumn: "current column",
    currentSquare: "square"
  },
  announcementScreenreaderOnlyDisplayed: false,
  announcementAllUsersDisplayed: false
}

let keyboardNavCapture = 0;
let capturedKeys = "";

document.querySelector('.board').addEventListener('keydown', (e) => { // The keyboard focus needs to be within the gameboard for the shortcut to function 
  if (e.defaultPrevented) {
    return; // Do nothing if the event was already processed. This helps avoids interfering with existing screen reader keyboard shortcuts
  }
  if(e.key === "Escape"){ // allow the user to cancel a partially captured action
    keyboardNavCapture = 0;
    capturedKeys = "";
  } else if(keyboardNavCapture === 0){    
    if(e.key === "r" || e.key === "R"){ // READ action. Capture the next key press to determine what to read
      keyboardNavCapture = 1;
      capturedKeys = "R";
      e.preventDefault();
    }
  } else {
    capturedKeys += e.key.toUpperCase();
    keyboardNavCapture = keyboardNavCapture - 1;
    e.preventDefault();
    if(keyboardNavCapture == 0){
      let actionType = capturedKeys[0];
      let actionDetails = capturedKeys.substring(1); 
      capturedKeys = "";
      if(actionType === "R"){ // READ action
        let activeCellId = parseInt(document.activeElement.id);
        if( (actionDetails === "R" || actionDetails === "C" || actionDetails === "S") && !Number.isNaN(activeCellId) ){
          let row = parseInt(document.activeElement.id[0]) * 10;
          let column = parseInt(document.activeElement.id[1]);
          let cellValue = "";
          let announcementScreenreaderOnly = messages[lang].reading + " ";
          switch (actionDetails){
            case "R": // Read out the current row
              announcementScreenreaderOnly += messages[lang].currentRow + ":";
              for (let col = 1; col < (size + 1); col++) {
                cellValue = document.getElementById("" + (row + col)).value;
                if(cellValue === ""){
                  cellValue = messages[lang].cellEmptyShort;
                } else if(document.getElementById("" + (row + col)).classList.contains('readonly')){
                  cellValue += " (" + messages[lang].cellFixedShort + ")";
                } else if(document.querySelector('#highlight').checked){
                  if(cellValue === document.getElementById("" + (row + col)).dataset.correct){
                    cellValue += " (" + messages[lang].cellCorrectShort + ")";  
                  } else {
                    cellValue += " (" + messages[lang].cellIncorrectShort + ")";
                  }                  
                }
                announcementScreenreaderOnly += " " + cellValue;
                if(col < size){
                  announcementScreenreaderOnly += ",";
                }
              }
              break;
              // switch statement continues for reading columns C and squares S
          }
          document.querySelector("#announcement-sr-only").innerText = announcementScreenreaderOnly;
        }
      }
    }
  }

});

在建立了键盘导航的基础后,我还为每个游戏控件添加了键盘快捷键。这些都是由C键和代表该控制的字符所触发的。例如,CR重新启动游戏,CS显示解决方案。

我还添加了一个 "我在哪里?"的功能(由W键触发)和一个 "去 "的功能(由G键和一个特定单元格的符号触发),以允许在游戏板上快速移动。你可以在最后的CodePen中看到这些是如何实现的。

测试、测试、测试!

理论上,我们现在应该有一个完全无障碍的数独谜题了!但是,实践和理论并不总是一致的,所以以多种方式测试游戏是很重要的。

作为第一个测试,一定要检查界面是否可以不用鼠标完全操作。我们的游戏以优异的成绩通过了这一测试!我们可以使用Tab键或键盘快捷键在网格中移动,而且游戏控制也都可以用键盘来触发。

:任何最终版本的游戏都需要足够的文档来告知用户任何键盘快捷方式。

在通过了基本的纯键盘测试后,现在是时候用屏幕阅读器进行测试了。

Windows PC上的NVDA是开发者测试的可靠选择,而且它是免费的。如果你有足够的资金获得一份JAWS,你也应该使用它。WebAIM的概述涵盖了使用NVDA进行测试所需的基础知识。如果您使用的是Mac,并且有VoiceOver屏幕阅读器,WebAIM有一个关于使用VoiceOver评估网络可及性的精彩介绍。

注意,在CodePen上使用读屏器是相当困难的;将代码导出并放在自己的服务器上进行测试会得到更好的结果。

我使用NVDA测试了数独游戏,几乎所有的东西都运行良好。然而,有一个意外的项目。事实证明,当NVDA进入一个readonly 单元时,它会从'focus' 模式中切换出来,回到'browse' 模式。这种模式切换杀死了键盘快捷键,使游戏的使用非常不直观。虽然发现这个错误是令人恼火的,但它很好地证明了测试的必要性。

我简单地尝试使用disabled 属性来代替,但NVDA甚至不会将键盘焦点放在被禁用的单元格上,所以这并不可行。相反,我重新修改了代码,完全删除了readonly 属性,然后我用JavaScript在这些单元格上执行只读行为。

在一个理想的世界里,我们的数独游戏现在应该在其他屏幕阅读器中进行测试,并从真实用户那里获得反馈。只要有可能,你都应该在开发过程中加入这最后一步。

结论

网络上的一切都可以被无障碍化。通过从一开始就对无障碍性进行规划,并在整个设计和构建过程中遵循POUR原则,你可以创建丰富的JavaScript界面,让每个人都能使用。

你可以在CodePen或这个独立的页面上查看最终代码(用于屏幕阅读器测试)。我还包括了一些额外的控件,这样你就可以在不使用NVDA的情况下模拟屏幕阅读器的输出,并了解如果你有部分视力或完全丧失视力,游戏可能会如何工作。让我知道你的想法!