Zugängliche Web-Apps mit React, TypeScript und AllyJS

Avatar of Daniel Yuschick
Daniel Yuschick am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Barrierefreiheit ist ein oft übersehener Aspekt der Webentwicklung. Ich würde behaupten, dass sie ebenso wichtig ist wie die Gesamtperformance und Code-Wiederverwendbarkeit. Wir rechtfertigen unsere endlose Jagd nach besserer Performance und responsivem Design mit Verweis auf die Nutzer, aber letztendlich werden diese Bestrebungen mit dem _Gerät_ des Nutzers im Hinterkopf durchgeführt, nicht mit dem Nutzer selbst und seinen potenziellen Behinderungen oder Einschränkungen.

Eine responsive App sollte eine sein, die ihre Inhalte basierend auf den Bedürfnissen des Nutzers liefert, nicht nur auf deren Gerät.

Glücklicherweise gibt es Werkzeuge, die den Lernaufwand bei der barrierefreien Entwicklung erleichtern. Zum Beispiel hat GitHub kürzlich seinen Barrierefreiheits-Fehler-Scanner, AccessibilityJS, veröffentlicht und Deque hat aXe. Dieser Artikel konzentriert sich auf ein weiteres Werkzeug: Ally.js, eine Bibliothek, die bestimmte Barrierefreiheitsfunktionen, -funktionen und -verhaltensweisen vereinfacht.


Einer der häufigsten Schmerzpunkte in Bezug auf Barrierefreiheit sind Dialogfenster.

Es gibt viele Überlegungen, die berücksichtigt werden müssen, um dem Benutzer den Dialog selbst zu kommunizieren, den einfachen Zugriff auf dessen Inhalt zu gewährleisten und beim Schließen zum Auslöser des Dialogs zurückzukehren.

Eine Demo auf der Ally.js-Website befasst sich mit dieser Herausforderung, was mir geholfen hat, ihre Logik in mein aktuelles Projekt zu übertragen, das React und TypeScript verwendet. Dieser Beitrag führt Sie durch die Erstellung einer zugänglichen Dialogkomponente.

Demo eines zugänglichen Dialogfensters mit Ally.js in React und TypeScript

Live-Demo ansehen

Projekt-Setup mit create-react-app

Bevor wir uns mit Ally.js beschäftigen, werfen wir einen Blick auf das initiale Setup des Projekts. Das Projekt kann von Git geklont werdenHub oder Sie können manuell folgen. Das Projekt wurde mit create-react-app im Terminal mit den folgenden Optionen gestartet.

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

Dies erstellte ein Projekt mit React und ReactDOM Version 15.6.1 sowie deren entsprechenden @types.

Nachdem das Projekt erstellt wurde, werfen wir einen Blick auf die Paketdatei und das Projekt-Scaffolding, das ich für diese Demo verwende.

Projektarchitektur und package.json-Datei

Wie Sie in der obigen Abbildung sehen können, sind mehrere zusätzliche Pakete installiert, aber für diesen Beitrag ignorieren wir diejenigen, die sich auf das Testen beziehen, und konzentrieren uns auf die beiden primären: **ally.js** und **babel-polyfill.**

Installieren wir beide Pakete über unser Terminal.

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

Lassen wir `/src/index.tsx` vorerst unberührt und springen direkt zu unserem App-Container.

App-Container

Der App-Container verwaltet unseren Zustand, den wir zum Umschalten des Dialogfensters verwenden. Dies könnte auch über Redux gehandhabt werden, aber der Einfachheit halber wird dies ausgeschlossen.

Definieren wir zuerst den Zustand und die Umschaltmethode.

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 });
  }
}

Das obige liefert uns einen Einstieg in unseren state und die Methode, die wir zum Umschalten des Dialogs verwenden werden. Als Nächstes würden wir eine Gliederung für unsere render-Methode erstellen.

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>
    );
  }
}

Machen Sie sich zu diesem Zeitpunkt keine Sorgen um die Stile und Klassennamen. Diese Elemente können beliebig gestylt werden. Fühlen Sie sich jedoch frei, das GitHub-Repository zu klonen, um die vollständigen Stile zu erhalten.

An diesem Punkt sollten wir ein grundlegendes Formular auf unserer Seite mit einem Button haben, der beim Klicken unseren showDialog-Zustandswert umschaltet. Dies kann durch die Verwendung der React Developer Tools bestätigt werden.

Lassen Sie uns nun auch das Dialogfenster mit dem Button umschalten. Dazu erstellen wir eine neue Dialog-Komponente.

Dialog-Komponente

Betrachten wir die Struktur unserer Dialog-Komponente, die als Wrapper für den Inhalt (children) dient, den wir ihr übergeben.

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>
    );
  }
}

Wir beginnen diese Komponente mit der Erstellung der Props-Schnittstelle. Dies ermöglicht uns die Übergabe von Titel und Beschreibung des Dialogs, zwei wichtige Elemente für die Barrierefreiheit. Wir übergeben auch eine close-Methode, die sich auf die toggleDialog-Methode aus dem App-Container bezieht. Schließlich erstellen wir die funktionale ref zum neu erstellten Dialogfenster, die später verwendet wird.

Die folgenden Stile können angewendet werden, um das Erscheinungsbild des Dialogfensters zu erzeugen.

.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;
}

Nun verbinden wir dies mit dem App-Container und gehen dann zu Ally.js über, um dieses Dialogfenster zugänglicher zu machen.

App-Container

Zurück im App-Container fügen wir im render-Methode eine Prüfung hinzu, damit jedes Mal, wenn sich der showDialog-Zustand aktualisiert, die Dialog-Komponente umgeschaltet wird.

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()}
        ...
    );
  }
}

Was wir hier getan haben, ist das Hinzufügen der Methoden checkForDialog und getDialog.

Innerhalb der render-Methode, die bei jeder Zustandsaktualisierung ausgeführt wird, gibt es einen Aufruf zur Ausführung von checkForDialog. Wenn Sie also auf den Button klicken, wird der showDialog-Zustand aktualisiert, was zu einem erneuten Rendern führt und checkForDialog erneut aufruft. Nur jetzt ist showDialog true, was getDialog auslöst. Diese Methode gibt die gerade erstellte Dialog-Komponente zurück, die auf dem Bildschirm gerendert werden soll.

Das obige Beispiel enthält eine Button-Komponente, die noch nicht gezeigt wurde.

Nun sollten wir die Möglichkeit haben, unseren Dialog zu öffnen und zu schließen. Werfen wir also einen Blick auf die Probleme in Bezug auf die Barrierefreiheit und wie wir sie mit Ally.js angehen können.


Öffnen Sie das Dialogfenster nur mit Ihrer Tastatur und versuchen Sie, Text in das Formular einzugeben. Sie werden feststellen, dass Sie durch das gesamte Dokument tabben müssen, um zu den Elementen innerhalb des Dialogs zu gelangen. Dies ist eine suboptimalle Erfahrung. Wenn sich der Dialog öffnet, sollte unser Fokus auf dem Dialog liegen – nicht auf dem Inhalt dahinter. Betrachten wir also unseren ersten Einsatz von Ally.js, um dieses Problem zu beheben.

Ally.js

Ally.js ist eine Bibliothek, die verschiedene Module bereitstellt, um gängige Barrierefreiheitsprobleme zu vereinfachen. Wir werden vier dieser Module für die Dialog-Komponente verwenden.

Das .popup-outer-container fungiert als Maske, die über die Seite gelegt wird und die Interaktion per Maus blockiert. Elemente hinter dieser Maske sind jedoch weiterhin per Tastatur zugänglich, was verhindert werden sollte. Dazu ist das erste Ally-Modul, das wir integrieren werden, maintain/disabled. Dieses dient dazu, eine beliebige Menge von Elementen vom Fokus per Tastatur zu deaktivieren und sie somit inert zu machen.

Leider ist die Implementierung von Ally.js in einem Projekt mit TypeScript nicht so einfach wie bei anderen Bibliotheken. Dies liegt daran, dass Ally.js keine dedizierten TypeScript-Definitionen bereitstellt. Aber keine Sorge, wir können unsere eigenen Module über TypeScript's types-Dateien deklarieren.

In dem ursprünglichen Screenshot, der das Scaffolding des Projekts zeigt, sehen wir ein Verzeichnis namens types. Erstellen wir dieses und darin eine Datei namens `global.d.ts`.

In dieser Datei deklarieren wir unser erstes Ally.js-Modul aus dem esm/-Verzeichnis, das ES6-Module bereitstellt, aber mit den Inhalten jedes kompiliert zu ES5. Diese werden empfohlen, wenn Build-Tools verwendet werden.

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

Nachdem dieses Modul nun in unserer globalen Typendatei deklariert wurde, kehren wir zur Dialog-Komponente zurück, um mit der Implementierung der Funktionalität zu beginnen.

Dialog-Komponente

Wir werden die gesamte Barrierefreiheitsfunktionalität für den Dialog in seiner Komponente unterbringen, um sie eigenständig zu halten. Importieren wir zuerst unser neu deklariertes Modul oben in der Datei.

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

Das Ziel der Verwendung dieses Moduls ist, dass, sobald die Dialog-Komponente gemountet wird, alles auf der Seite deaktiviert wird, während der Dialog selbst herausgefiltert wird.

Wir verwenden also den componentDidMount Lifecycle Hook, um jegliche Ally.js-Funktionalität anzubringen.

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();
  }
  ...
}

Wenn die Komponente gemountet wird, speichern wir die Disabled-Funktionalität in der neu erstellten Komponenteneigenschaft disableHandle. Da es noch keine definierten Typen für Ally.js gibt, können wir eine generische Handle-Schnittstelle mit der Eigenschaft disengage erstellen. Wir werden diesen Handle auch für andere Ally-Module verwenden, daher halten wir ihn generisch.

Durch die Verwendung der filter-Eigenschaft des Disabled-Imports können wir Ally.js mitteilen, alles im Dokument zu deaktivieren, außer unserer dialog-Referenz.

Zuletzt, wenn die Komponente unmountet, wollen wir dieses Verhalten entfernen. Daher rufen wir im componentWillUnmount Hook disengage() auf dem disableHandle auf.


Wir werden nun denselben Prozess für die letzten Schritte zur Verbesserung der Dialog-Komponente befolgen. Wir werden die zusätzlichen Ally-Module verwenden

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

Aktualisieren wir die `global.d.ts`-Datei, damit sie diese zusätzlichen Module deklariert.

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

Und importieren wir sie alle in die Dialog-Komponente.

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

Nachdem das Dokument mit Ausnahme unseres Dialogs deaktiviert wurde, müssen wir nun den Tab-Zugriff weiter einschränken. Derzeit springt beim Tabben zum letzten Element im Dialog das erneute Drücken von Tab zum UI des Browsers (wie die Adressleiste). Stattdessen wollen wir tab-focus nutzen, um sicherzustellen, dass die Tab-Taste zum Anfang des Dialogs zurückkehrt und nicht zum Fenster springt.

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();
  }
  ...
}

Wir folgen hier demselben Prozess wie beim disabled-Modul. Erstellen wir eine focusHandle-Eigenschaft, die den Wert des TabFocus-Modul-Imports annimmt. Wir definieren den context als die aktive dialog-Referenz beim Mounten und rufen dann disengage() für dieses Verhalten auf, wieder, wenn die Komponente unmountet.

Zu diesem Zeitpunkt sollte das Drücken von Tab bei geöffnetem Dialogfenster die Elemente innerhalb des Dialogs durchlaufen.

Wäre es nicht schön, wenn das erste Element unseres Dialogs bereits beim Öffnen fokussiert wäre?

Erster Tab-Fokus

Durch die Nutzung des first-tabbable-Moduls können wir den Fokus auf das erste Element des Dialogfensters setzen, wann immer es gemountet wird.

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();
  }
  ...
}

Innerhalb des componentDidMount Hooks erstellen wir die Variable element und weisen ihr den FirstTab-Import zu. Dies gibt das erste tab-fähige Element innerhalb des von uns bereitgestellten context zurück. Sobald dieses Element zurückgegeben ist, wird durch Aufrufen von element.focus() automatisch der Fokus angewendet.

Nachdem wir nun das Verhalten innerhalb des Dialogs ziemlich gut zum Laufen gebracht haben, wollen wir die Tastaturzugänglichkeit verbessern. Als strenger Laptop-Benutzer neige ich dazu, instinktiv esc zu drücken, wenn ich einen Dialog oder ein Popup schließen möchte. Normalerweise würde ich meinen eigenen Event-Listener schreiben, um dieses Verhalten zu handhaben, aber Ally.js bietet das when/key-Modul, um auch diesen Prozess zu vereinfachen.

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();
  }
  ...
}

Auch hier stellen wir eine Handle-Eigenschaft für unsere Klasse bereit, die es uns ermöglicht, die esc-Funktionalität beim Mounten einfach zu binden und sie dann beim Unmounten zu disengage(). Und damit können wir unseren Dialog nun einfach über die Tastatur schließen, ohne unbedingt zu einem bestimmten Schließen-Button tabben zu müssen.

Zuletzt (puh!), beim Schließen des Dialogfensters sollte der Fokus des Benutzers auf das Element zurückkehren, das es ausgelöst hat. In diesem Fall der Show Dialog-Button im App-Container. Dies ist nicht in Ally.js integriert, aber eine empfohlene Best Practice, die, wie Sie sehen werden, mit wenig Aufwand hinzugefügt werden kann.

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();
  }
  ...
}

Was hier getan wurde, ist, dass eine Eigenschaft, focusedElementBeforeDialogOpened, unserer Klasse hinzugefügt wurde. Immer wenn die Komponente gemountet wird, speichern wir das aktuelle activeElement im Dokument in dieser Eigenschaft.

Es ist wichtig, dies *vor* dem Deaktivieren des gesamten Dokuments zu tun, sonst gibt document.activeElement null zurück.

Dann, wie wir es beim Setzen des Fokus auf das erste Element im Dialog getan haben, werden wir die .focus()-Methode unseres gespeicherten Elements in componentWillUnmount verwenden, um den Fokus auf den ursprünglichen Button beim Schließen des Dialogs anzuwenden. Diese Funktionalität wurde in einen Typ-Guard gekapselt, um sicherzustellen, dass das Element die focus()-Methode unterstützt.


Nachdem unsere Dialog-Komponente nun funktioniert, zugänglich und in sich geschlossen ist, sind wir bereit, unsere App zu bauen. Aber das Ausführen von yarn test oder yarn build führt zu einem Fehler. Etwas in dieser Art

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

   SyntaxError: Unexpected token import

Obwohl Create React App und sein Test-Runner Jest ES6-Module unterstützen, verursacht dies immer noch ein Problem mit den ESM deklarierten Modulen. Dies bringt uns zum letzten Schritt der Integration von Ally.js mit React, und das ist das babel-polyfill-Paket.

Ganz am Anfang dieses Beitrags (buchstäblich vor Ewigkeiten!) habe ich zusätzliche Pakete gezeigt, die installiert werden müssen, das zweite davon war babel-polyfill. Wenn dies installiert ist, gehen wir zum Einstiegspunkt unserer App, in diesem Fall ./src/index.tsx.

Index.tsx

Ganz oben in dieser Datei importieren wir babel-polyfill. Dies emuliert eine vollständige ES2015+-Umgebung und ist für die Verwendung in einer Anwendung und nicht in einer Bibliothek/einem Werkzeug gedacht.

import 'babel-polyfill';

Damit können wir zu unserem Terminal zurückkehren, um die Test- und Build-Skripte von create-react-app ohne Fehler auszuführen.

Demo eines zugänglichen Dialogfensters mit Ally.js in React und TypeScript

Live-Demo ansehen


Nachdem Ally.js nun in Ihr React- und TypeScript-Projekt integriert ist, können weitere Schritte unternommen werden, um sicherzustellen, dass Ihre Inhalte von allen Benutzern konsumiert werden können, nicht nur von allen ihren Geräten.

Weitere Informationen zur Barrierefreiheit und weitere großartige Ressourcen finden Sie unter diesen Links