使用 React、TypeScript 和 AllyJS 构建无障碍 Web 应用

Avatar of Daniel Yuschick
Daniel Yuschick

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费信用额度!

无障碍性是 Web 开发中经常被忽视的一个方面。 我认为它与整体性能和代码可重用性一样重要。 我们通过引用用户来证明我们对更高性能和响应式设计的无休止追求,但最终这些追求是以用户的 *设备* 为中心的,而不是用户本身及其潜在的残疾或限制。

响应式应用程序应该根据用户的需求,而不仅仅是他们的设备,来提供内容。

幸运的是,有一些工具可以帮助减轻无障碍开发的学习曲线。 例如,GitHub 最近发布了他们的无障碍错误扫描器,AccessibilityJS 并且 Deque 有 aXe。 本文将重点介绍另一种工具:Ally.js,这是一个简化某些无障碍功能、函数和行为的库。


无障碍性方面最常见的问题之一就是对话框窗口。

在与用户交流对话框本身、确保轻松访问其内容以及在关闭时返回到对话框的触发器方面,有很多需要考虑的地方。

Ally.js 网站上的一个演示解决了这个问题,这帮助我将它的逻辑移植到我当前使用 React 和 TypeScript 的项目中。 这篇文章将逐步介绍如何构建一个无障碍对话框组件。

使用 Ally.js 在 React 和 TypeScript 中构建无障碍对话框窗口的演示

查看实时演示

使用 create-react-app 设置项目

在开始使用 Ally.js 之前,让我们看一下项目的初始设置。 该项目可以从 Git 克隆Hub,或者您可以手动进行操作。 该项目是使用 create-react-app 在终端中使用以下选项启动的

create-react-app my-app --scripts-version=react-scripts-ts

这创建了一个使用 React 和 ReactDOM 版本 15.6.1 以及它们的相应 @types 的项目。

项目创建后,让我们继续查看用于此演示的包文件和项目脚手架。

项目架构和 package.json 文件

如上图所示,安装了几个额外的包,但对于这篇文章,我们将忽略与测试相关的包,并专注于主要的两个包,**ally.js** 和 **babel-polyfill**。

让我们通过终端安装这两个包。

yarn add ally.js --dev && yarn add babel-polyfill --dev

现在,让我们先保留 `/src/index.tsx`,直接跳到我们的 App 容器。

App 容器

App 容器将处理我们用于切换对话框窗口的状态。 现在,这也可以由 Redux 处理,但为了简洁起见,我们将省略它。

让我们首先定义状态和切换方法。

interface AppState {
  showDialog: boolean;
}

class App extends React.Component<{}, AppState> {
  state: AppState;

  constructor(props: {}) {
    super(props);

    this.state = {
      showDialog: false
    };
  }

  toggleDialog() {
    this.setState({ showDialog: !this.state.showDialog });
  }
}

以上内容为我们提供了 state 和用于切换对话框的方法的起点。 接下来将是创建 render 方法的轮廓。

class App extends React.Component<{}, AppState> {
  ...

  render() {
    return (
      <div className="site-container">
        <header>
          <h1>Ally.js with React &amp; Typescript</h1>
        </header>
        <main className="content-container">
          <div className="field-container">
            <label htmlFor="name-field">Name:</label>
            <input type="text" id="name-field" placeholder="Enter your name" />
          </div>
          <div className="field-container">
            <label htmlFor="food-field">Favourite Food:</label>
            <input type="text" id="food-field" placeholder="Enter your favourite food" />
          </div>
          <div className="field-container">
            <button
              className='btn primary'
              tabIndex={0}
              title='Open Dialog'
              onClick={() => this.toggleDialog()}
            >
              Open Dialog
            </button>
          </div>
        </main>
      </div>
    );
  }
}

现在不必过多关注样式和类名。 这些元素可以根据您的需要进行样式设置。 但是,请随时 克隆 GitHub 仓库 以获取完整样式。

此时,我们应该在页面上有一个基本表单,有一个按钮,当单击时会切换我们的 showDialog 状态值。 这可以通过使用 React 的开发者工具 来确认。

因此,现在让我们让对话框窗口也随着按钮的切换而切换。 为了做到这一点,让我们创建一个新的 Dialog 组件。

Dialog 组件

让我们看一下 Dialog 组件的结构,它将充当我们传递给它的任何内容(children)的包装器。

interface Props {
  children: object;
  title: string;
  description: string;
  close(): void;
}

class Dialog extends React.Component<Props> {
  dialog: HTMLElement | null;

  render() {
    return (
      <div
        role="dialog"
        tabIndex={0}
        className="popup-outer-container"
        aria-hidden={false}
        aria-labelledby="dialog-title"
        aria-describedby="dialog-description"
        ref={(popup) => {
          this.dialog = popup;
          }
        }
      >
        <h5 
          id="dialog-title"
          className="is-visually-hidden"
        >
          {this.props.title}
        </h5>
        <p 
          id="dialog-description"
          className="is-visually-hidden"
        >
          {this.props.description}
        </p>
        <div className="popup-inner-container">
          <button
            className="close-icon"
            title="Close Dialog"
            onClick={() => {
              this.props.close();
            }}
          >
            ×
          </button>
          {this.props.children}
        </div>
      </div>
    );
  }
}

我们从创建 Props 接口开始。 这将允许我们传递对话框的标题和描述,这是无障碍性的两个重要部分。 我们还将传递一个 close 方法,它将引用 App 容器中的 toggleDialog 方法。 最后,我们创建了新创建对话框窗口的功能 ref,将在后面使用。

以下样式可以应用于创建对话框窗口的外观。

.popup-outer-container {
  align-items: center;
  background: rgba(0, 0, 0, 0.2);
  display: flex;
  height: 100vh;
  justify-content: center;
  padding: 10px;
  position: absolute;
  width: 100%;
  z-index: 10;
}

.popup-inner-container {
  background: #fff;
  border-radius: 4px;
  box-shadow: 0px 0px 10px 3px rgba(119, 119, 119, 0.35);
  max-width: 750px;
  padding: 10px;
  position: relative;
  width: 100%;
}

.popup-inner-container:focus-within {
  outline: -webkit-focus-ring-color auto 2px;
}

.close-icon {
  background: transparent;
  color: #6e6e6e;
  cursor: pointer;
  font: 2rem/1 sans-serif;
  position: absolute;
  right: 20px;
  top: 1rem;
}

现在,让我们将它与 App 容器联系起来,然后进入 Ally.js,使这个对话框窗口更易于访问。

App 容器

回到 App 容器中,让我们在 render 方法中添加一个检查,以便每次 showDialog 状态更新时,Dialog 组件都会切换。

class App extends React.Component<{}, AppState> {
  ...

  checkForDialog() {
    if (this.state.showDialog) {
      return this.getDialog();
    } else {
      return false;
    }
  }

  getDialog() {
    return (
      <Dialog
        title="Favourite Holiday Dialog"
        description="Add your favourite holiday to the list"
        close={() => { this.toggleDialog(); }}
      >
        <form className="dialog-content">
          <header>
            <h1 id="dialog-title">Holiday Entry</h1>
            <p id="dialog-description">Please enter your favourite holiday.</p>
          </header>
          <section>
            <div className="field-container">
              <label htmlFor="within-dialog">Favourite Holiday</label>
              <input id="within-dialog" />
            </div>
          </section>
          <footer>
            <div className="btns-container">
              <Button
                type="primary"
                clickHandler={() => { this.toggleDialog(); }}
                msg="Save"
              />
            </div>
          </footer>
        </form>
      </Dialog>
    );
  }

  render() {
    return (
      <div className="site-container">
        {this.checkForDialog()}
        ...
    );
  }
}

我们在这里做了的是添加了 checkForDialoggetDialog 方法。

render 方法中,它会在每次状态更新时运行,有一个调用来运行 checkForDialog。 因此,单击按钮后,showDialog 状态将更新,导致重新渲染,再次调用 checkForDialog。 只是现在,showDialog 为 true,触发 getDialog。 此方法返回我们刚刚构建的 Dialog 组件,以便将其渲染到屏幕上。

以上示例包含一个 Button 组件,它尚未显示。

现在,我们应该能够打开和关闭我们的对话框。 因此,让我们看一下在无障碍性方面存在哪些问题,以及如何使用 Ally.js 来解决这些问题。


只使用键盘打开对话框窗口,然后尝试在表单中输入文本。 您会注意到,您必须在整个文档中进行制表符操作才能到达对话框中的元素。 这不是一个理想的体验。 当对话框打开时,我们的焦点应该在对话框上,而不是后面的内容上。 因此,让我们看一下 Ally.js 的第一个用例,以开始解决这个问题。

Ally.js

Ally.js 是一个库,它提供了各种模块来帮助简化常见的无障碍性挑战。 我们将使用其中的四个模块来构建 Dialog 组件。

.popup-outer-container 充当一个遮罩,覆盖页面,阻止鼠标交互。 但是,此遮罩后面的元素仍然可以通过键盘访问,这应该被禁止。 为了做到这一点,我们将要合并的第一个 Ally 模块是 maintain/disabled。 它用于禁用任何一组元素通过键盘获得焦点,使其本质上处于惰性状态。

不幸的是,将 Ally.js 实施到使用 TypeScript 的项目中不像其他库那样简单。 这是因为 Ally.js 没有提供专门的 TypeScript 定义集。 但不用担心,因为我们可以通过 TypeScript 的 types 文件声明我们自己的模块。

在显示项目脚手架的原始屏幕截图中,我们看到一个名为 types 的目录。 让我们创建它,并在里面创建一个名为 `global.d.ts` 的文件。

在这个文件中,让我们从 esm/ 目录声明我们的第一个 Ally.js 模块,它提供了 ES6 模块,但每个模块的内容都编译到 ES5。 建议在使用构建工具时使用这些模块。

declare module 'ally.js/esm/maintain/disabled';

现在,这个模块已在我们全局类型文件中声明,让我们回到 Dialog 组件中,开始实施功能。

Dialog 组件

我们将为 Dialog 添加所有无障碍性功能,以使其保持独立。 让我们首先在文件顶部导入新声明的模块。

import Disabled from 'ally.js/esm/maintain/disabled';

使用此模块的目标是在 Dialog 组件挂载后,页面上的所有内容都将被禁用,同时过滤掉对话框本身。

因此,让我们使用 componentDidMount 生命周期的钩子来附加任何 Ally.js 功能。

interface Handle {
  disengage(): void;
}

class Dialog extends React.Component<Props, {}> {
  dialog: HTMLElement | null;
  disabledHandle: Handle;

  componentDidMount() {
    this.disabledHandle = Disabled({
      filter: this.dialog,
    });
  }

  componentWillUnmount() {
    this.disabledHandle.disengage();
  }
  ...
}

当组件挂载时,我们将 Disabled 功能存储到新创建的组件属性 disableHandle 中。 由于 Ally.js 还没有定义类型,因此我们可以创建一个包含 disengage 函数属性的泛型 Handle 接口。 我们将再次为其他 Ally 模块使用这个 Handle,因此保持它泛型。

通过使用 Disabled 导入的 filter 属性,我们可以告诉 Ally.js 禁用文档中的所有内容,除了我们的 dialog 引用。

最后,每当组件卸载时,我们希望删除此行为。因此,在 componentWillUnmount 钩子中,我们 disengage()disableHandle


现在,我们将遵循相同的流程来完成改进 Dialog 组件的最后步骤。我们将使用额外的 Ally 模块

  • maintain/tab-focus
  • query/first-tabbable
  • when/key

让我们更新 global.d.ts 文件,以便它声明这些额外的模块。

declare module 'ally.js/esm/maintain/disabled';
declare module 'ally.js/esm/maintain/tab-focus';
declare module 'ally.js/esm/query/first-tabbable';
declare module 'ally.js/esm/when/key';

以及将它们全部导入到 Dialog 组件中。

import Disabled from 'ally.js/esm/maintain/disabled';
import TabFocus from 'ally.js/esm/maintain/tab-focus';
import FirstTab from 'ally.js/esm/query/first-tabbable';
import Key from 'ally.js/esm/when/key';

Tab Focus

在禁用文档(除了我们的对话框)之后,我们现在需要进一步限制 Tab 键访问。目前,在 Tab 键移动到对话框中的最后一个元素后,再次按下 Tab 键将开始将焦点移动到浏览器的 UI(例如地址栏)。相反,我们希望利用 tab-focus 来确保 Tab 键将重置到对话框的开头,而不是跳到窗口。

class Dialog extends React.Component<Props> {
  dialog: HTMLElement | null;
  disabledHandle: Handle;
  focusHandle: Handle;

  componentDidMount() {
    this.disabledHandle = Disabled({
      filter: this.dialog,
    });

    this.focusHandle = TabFocus({
      context: this.dialog,
    });
  }

  componentWillUnmount() {
    this.disabledHandle.disengage();
    this.focusHandle.disengage();
  }
  ...
}

我们在这里遵循与 disabled 模块相同的过程。让我们创建一个 focusHandle 属性,它将假定 TabFocus 模块导入的值。我们将 context 定义为挂载时活动的 dialog 引用,然后在组件卸载时再次 disengage() 此行为。

此时,当对话框窗口打开时,按 Tab 键应该在对话框本身内的元素之间循环。

现在,如果我们的对话框的第一个元素在打开时就已经获得焦点,那不是很好吗?

First Tab Focus

利用 first-tabbable 模块,我们能够在对话框窗口挂载时将焦点设置到对话框窗口的第一个元素。

class Dialog extends React.Component<Props> {
  dialog: HTMLElement | null;
  disabledHandle: Handle;
  focusHandle: Handle;

  componentDidMount() {
    this.disabledHandle = Disabled({
      filter: this.dialog,
    });

    this.focusHandle = TabFocus({
      context: this.dialog,
    });

    let element = FirstTab({
      context: this.dialog,
      defaultToContext: true,
    });
    element.focus();
  }
  ...
}

componentDidMount 钩子中,我们创建 element 变量并将其赋值给我们的 FirstTab 导入。这将返回我们提供的 context 中的第一个可 Tab 键元素。一旦返回该元素,调用 element.focus() 将自动应用焦点。

现在,由于我们已经让对话框内的行为工作得很好,我们希望改善键盘可访问性。作为一个严格的笔记本电脑用户(没有外接鼠标、显示器或任何外围设备),我倾向于本能地按下 esc 键,每当我想关闭任何对话框或弹出窗口时。通常,我会编写自己的事件监听器来处理此行为,但 Ally.js 提供了 when/key 模块来简化此过程。

class Dialog extends React.Component<Props> {
  dialog: HTMLElement | null;
  disabledHandle: Handle;
  focusHandle: Handle;
  keyHandle: Handle;

  componentDidMount() {
    this.disabledHandle = Disabled({
      filter: this.dialog,
    });

    this.focusHandle = TabFocus({
      context: this.dialog,
    });

    let element = FirstTab({
      context: this.dialog,
      defaultToContext: true,
    });
    element.focus();

    this.keyHandle = Key({
      escape: () => { this.props.close(); },
    });
  }

  componentWillUnmount() {
    this.disabledHandle.disengage();
    this.focusHandle.disengage();
    this.keyHandle.disengage();
  }
  ...
}

同样,我们为类提供了一个 Handle 属性,它将允许我们轻松地将 esc 功能绑定到挂载,然后在卸载时 disengage() 它。就这样,我们现在能够通过键盘轻松关闭对话框,而无需专门 Tab 键到特定的关闭按钮。

最后(whew!),在关闭对话框窗口后,用户的焦点应该返回到触发它的元素。在本例中,是 App 容器中的“显示对话框”按钮。这并非 Ally.js 的内置功能,但这是一个推荐的最佳实践,正如您将看到的,只需很少的麻烦即可添加。

class Dialog extends React.Component<Props> {
  dialog: HTMLElement | null;
  disabledHandle: Handle;
  focusHandle: Handle;
  keyHandle: Handle;
  focusedElementBeforeDialogOpened: HTMLInputElement | HTMLButtonElement;

  componentDidMount() {
    if (document.activeElement instanceof HTMLInputElement ||
      document.activeElement instanceof HTMLButtonElement) {
      this.focusedElementBeforeDialogOpened = document.activeElement;
    }

    this.disabledHandle = Disabled({
      filter: this.dialog,
    });

    this.focusHandle = TabFocus({
      context: this.dialog,
    });

    let element = FirstTab({
      context: this.dialog,
      defaultToContext: true,
    });

    this.keyHandle = Key({
      escape: () => { this.props.close(); },
    });
    element.focus();
  }

  componentWillUnmount() {
    this.disabledHandle.disengage();
    this.focusHandle.disengage();
    this.keyHandle.disengage();
    this.focusedElementBeforeDialogOpened.focus();
  }
  ...
}

这里所做的是,在我们的类中添加了一个属性 focusedElementBeforeDialogOpened。每当组件挂载时,我们将文档中的当前 activeElement 存储到此属性中。

重要的是要在我们禁用整个文档 *之前* 执行此操作,否则 document.activeElement 将返回 null。

然后,就像我们对将焦点设置到对话框中的第一个元素所做的那样,我们将在 componentWillUnmount 上使用存储的元素的 .focus() 方法,在关闭对话框时将焦点应用到原始按钮。此功能已封装在一个类型保护程序中,以确保元素支持 focus() 方法。


现在,由于我们的 Dialog 组件已经可以正常工作、可访问且自包含,我们已准备好构建我们的应用程序。但是,运行 yarn testyarn build 将导致错误。类似于以下内容

[path]/node_modules/ally.js/esm/maintain/disabled.js:21
   import nodeArray from '../util/node-array';
   ^^^^^^

   SyntaxError: Unexpected token import

尽管 Create React App 及其测试运行器 Jest 支持 ES6 模块,但 ESM 声明的模块仍然会导致问题。因此,这将我们带到将 Ally.js 集成到 React 的最后一步,即 babel-polyfill 包。

在本文的开头(实际上是很久以前!),我展示了要安装的额外包,第二个是 babel-polyfill。安装完成后,让我们进入应用程序的入口点,在本例中是 ./src/index.tsx

Index.tsx

在这个文件的顶部,让我们导入 babel-polyfill。这将模拟一个完整的 ES2015+ 环境,并且旨在用于应用程序而不是库/工具。

import 'babel-polyfill';

有了它,我们可以返回到终端,从 create-react-app 运行测试和构建脚本,而不会出现任何错误。

使用 Ally.js 在 React 和 TypeScript 中构建无障碍对话框窗口的演示

查看实时演示


现在,Ally.js 已集成到您的 React 和 TypeScript 项目中,您可以采取更多步骤来确保您的内容可以被所有用户访问,而不仅仅是所有用户的设备。

有关可访问性和其他优秀资源的更多信息,请访问以下资源