Wie wir die Barrierefreiheit unseres Single-Page-App-Menüs verbessert haben

Avatar of Luke Denton
Luke Denton am

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

Ich habe kürzlich mit meinem Team für einen Kunden an einer Progressive Web App (PWA) gearbeitet. Wir verwenden React mit clientseitigem Routing über React Router, und eines der ersten Elemente, das wir erstellt haben, war das Hauptmenü. Menüs sind eine Schlüsselkomponente jeder Website oder App. So bewegen sich die Leute tatsächlich fort, daher war die Barrierefreiheit für das Team eine sehr hohe Priorität.

Aber im Prozess haben wir gelernt, dass die Erstellung eines barrierefreien Hauptmenüs in einer PWA nicht so einfach ist, wie es klingt. Ich dachte, ich würde einige dieser Lektionen mit Ihnen teilen und wie wir sie überwunden haben.

Was die Anforderungen betrifft, so wollten wir ein Menü, das Benutzer nicht nur mit der Maus, sondern auch mit der Tastatur bedienen können. Die Akzeptanzkriterien waren, dass ein Benutzer durch die Menüpunkte der obersten Ebene und die Untermenüpunkte, die sonst nur sichtbar wären, wenn ein Mausbenutzer über einen Menüpunkt der obersten Ebene fährt, mit der Tabulatortaste navigieren können sollte. Und natürlich wollten wir, dass ein Fokusring den Elementen folgt, die den Fokus haben.

Das erste, was wir tun mussten, war, das vorhandene CSS zu aktualisieren, das so eingerichtet war, dass ein Untermenü angezeigt wird, wenn über einen Menüpunkt der obersten Ebene gefahren wird. Wir haben zuvor die Eigenschaft visibility verwendet und zwischen visible und hidden im Hover-Zustand des übergeordneten Containers gewechselt. Das funktioniert gut für Mausanwender, aber für Tastaturnutzer bewegt sich der Fokus nicht automatisch auf ein Element, das auf visibility: hidden gesetzt ist (dasselbe gilt für Elemente, die display: none erhalten). Also haben wir die Eigenschaft visibility entfernt und stattdessen einen sehr großen negativen Positionsbetrag verwendet.

.menu-item {
  position: relative;
}

.sub-menu {
  position: absolute
  left: -100000px; /* Kicking off  the page instead of hiding visiblity */
}

.menu-item:hover .sub-menu {
  left: 0;
}

Das funktioniert für Mausanwender einwandfrei. Aber für Tastaturnutzer war das Untermenü immer noch nicht sichtbar, obwohl der Fokus innerhalb dieses Untermenüs lag! Um das Untermenü sichtbar zu machen, wenn ein Element darin den Fokus hat, mussten wir :focus und :focus-within auf dem übergeordneten Container nutzen.

.menu-item {
  position: relative;
}

.sub-menu {
  position: absolute
  left: -100000px;
}

.menu-item:hover .sub-menu,
.menu-item:focus .sub-menu,
.menu-item:focus-within .sub-menu {
  left: 0;
}

Dieser aktualisierte Code ermöglicht es den Untermenüs, zu erscheinen, wenn jeder der Links innerhalb dieses Menüs den Fokus erhält. Sobald der Fokus zum nächsten Untermenü wechselt, verschwindet das erste und das zweite wird sichtbar. Perfekt! Wir betrachteten diese Aufgabe als erledigt, sodass ein Pull Request erstellt und in den Hauptzweig eingearbeitet wurde.

Aber dann haben wir das Menü am nächsten Tag im Staging selbst verwendet, um eine weitere Seite zu erstellen, und sind auf ein Problem gestoßen. Nach der Auswahl eines Menüpunkts – unabhängig davon, ob es sich um einen Klick oder eine Tabulator-Taste handelt – wurde das Menü selbst nicht ausgeblendet. Mausanwender mussten irgendwo ins Leere klicken, um den Fokus zu löschen, und Tastaturnutzer waren völlig gefangen! Sie konnten die Esc-Taste nicht drücken, um den Fokus zu löschen, noch irgendeine andere Tastenkombination. Stattdessen mussten Tastaturnutzer lange genug die Tabulatortaste drücken, um den Fokus durch das Menü auf ein anderes Element zu bewegen, das keine große Dropdown-Liste zur Obstruktion ihrer Sicht verursachte.

Der Grund, warum das Menü sichtbar blieb, war, dass der ausgewählte Menüpunkt den Fokus behielt. Clientseitiges Routing in einer Single-Page-Anwendung (SPA) bedeutet, dass nur ein Teil der Seite aktualisiert wird; es gibt keinen vollständigen Seitenreload.

Es gab noch ein weiteres Problem, das wir bemerkten: Es war für einen Tastaturnutzer schwierig, unseren "Zum Inhalt springen"-Link zu verwenden. Webbenutzer erwarten typischerweise, dass das einmalige Drücken der Tabulatortaste einen "Zum Inhalt springen"-Link hervorhebt, aber unsere Menüimplementierung hat das kaputt gemacht. Wir mussten ein Muster finden, um das "Fokuslöschen" effektiv zu replizieren, das Browser sonst kostenlos bei einem vollständigen Seitenreload geben würden.

Die erste Option, die wir ausprobiert haben, war die einfachste: Hinzufügen einer onClick-Prop zum Link-Komponenten von React Router, die document.activeElement.blur() aufruft, wenn ein Link im Menü ausgewählt wird.

const Menu = () => {
  const clearFocus = () => {
    document.activeElement.blur();
  }

  return (
    <ul className="menu">
      <li className="menu-item">
        <Link to="/" onClick={clearFocus}>Home</Link>
      </li>
      <li className="menu-item">
        <Link to="/products" onClick={clearFocus}>Products</Link>
        <ul className="sub-menu">
          <li>
            <Link to="/products/tops" onClick={clearFocus}>Tops</Link>
          </li>
          <li>
            <Link to="/products/bottoms" onClick={clearFocus}>Bottoms</Link>
          </li>
          <li>
            <Link to="/products/accessories" onClick={clearFocus}>Accessories</Link>
          </li>
        </ul>
      </li>
    </ul>
  );
}

Dieser Ansatz funktionierte gut zum "Schließen" des Menüs nach der Auswahl eines Elements. Wenn ein Tastaturnutzer jedoch nach der Auswahl eines der Menü-Links die Tabulatortaste drückte, wurde der *nächste* Link fokussiert. Wie bereits erwähnt, würde das Drücken der Tabulatortaste nach einem Navigationsereignis idealerweise zuerst den "Zum Inhalt springen"-Link fokussieren.

Zu diesem Zeitpunkt wussten wir, dass wir den Fokus programmatisch auf ein anderes Element erzwingen mussten, vorzugsweise eines, das hoch oben im DOM liegt. Auf diese Weise, wenn ein Benutzer nach einem Navigationsereignis mit der Tabulatortaste navigiert, erreicht er den Anfang der Seite oder dessen Nähe, ähnlich wie bei einem vollständigen Seitenreload, was den Zugriff auf den Sprunglink erheblich erleichtert.

Wir haben zunächst versucht, den Fokus auf das <body>-Element selbst zu erzwingen, aber das funktionierte nicht, da der Body kein Element ist, mit dem der Benutzer interagieren kann. Es gab keine Möglichkeit, dass er den Fokus erhält.

Die nächste Idee war, den Fokus auf das Logo im Header zu erzwingen, da dies selbst nur ein Link zur Startseite ist und den Fokus erhalten kann. In diesem speziellen Fall befand sich das Logo jedoch *unterhalb* des "Zum Inhalt springen"-Links im DOM, was bedeutet, dass ein Benutzer Umschalt + Tabulatortaste drücken müsste, um dorthin zu gelangen. Nicht gut.

Wir entschieden uns schließlich, dass wir ein interaktives Element rendern mussten, zum Beispiel ein Anker-Element, im DOM, an einer Stelle, die *über* dem "Zum Inhalt springen"-Link liegt. Dieses neue Anker-Element würde so gestylt werden, dass es unsichtbar ist und Benutzer es nicht mit "normalen" Webinteraktionen fokussieren können (d. h. es ist aus dem normalen Tabulatorfluss entfernt). Wenn ein Benutzer einen Menüpunkt auswählt, wird der Fokus programmatisch auf dieses neue Anker-Element erzwungen, was bedeutet, dass das erneute Drücken der Tabulatortaste direkt auf den "Zum Inhalt springen"-Link fokussiert. Es bedeutete auch, dass das Untermenü sich sofort ausblendete, sobald ein Menüpunkt ausgewählt wurde.

const App = () => {
  const focusResetRef = React.useRef();

  const handleResetFocus = () => {
    focusResetRef.current.focus();
  };

  return (
    <Fragment>
      <a
        ref={focusResetRef}
        href="javascript:void(0)"
        tabIndex="-1"
        style={{ position: "fixed", top: "-10000px" }}
        aria-hidden
      >Focus Reset</a>
      <a href="#main" className="jump-to-content-a11y-styles">Jump To Content</a>
      <Menu onSelectMenuItem={handleResetFocus} />
      ...
    </Fragment>
  )
}

Einige Anmerkungen zu diesem neuen "Focus Reset"-Anker-Element

  • href ist auf javascript:void(0) gesetzt, sodass, *falls* ein Benutzer mit dem Element interagieren kann, nichts passiert. Wenn ein Benutzer beispielsweise sofort nach Auswahl eines Menüpunkts die Eingabetaste drückt, wird die Interaktion ausgelöst. In diesem Fall möchten wir nicht, dass die Seite etwas tut oder sich die URL ändert.
  • tabIndex ist auf -1 gesetzt, damit ein Benutzer den Fokus nicht "normal" auf dieses Element legen kann. Es bedeutet auch, dass beim ersten Drücken der Tabulatortaste nach dem Laden einer Seite dieses Element nicht fokussiert wird, sondern stattdessen der "Zum Inhalt springen"-Link.
  • style verschiebt das Element einfach aus dem Viewport. Das Setzen auf position: fixed stellt sicher, dass es aus dem Dokumentfluss entfernt wird, sodass kein vertikaler Platz für das Element zugewiesen wird.
  • aria-hidden teilt Screenreadern mit, dass dieses Element nicht wichtig ist, also kündigen Sie es den Benutzern nicht an.

Aber wir dachten, wir könnten das noch weiter verbessern! Stellen wir uns ein Mega-Menü vor, und das Menü wird nicht automatisch ausgeblendet, wenn ein Mausanwender auf einen Link klickt. Das führt zu Frustration. Ein Benutzer müsste seine Maus präzise auf einen Bereich der Seite bewegen, der nicht das Menü enthält, um den :hover-Status zu löschen und somit das Menü schließen zu lassen.

Was wir brauchen, ist ein "Zwangs-Löschen" des Hover-Zustands. Das können wir mit Hilfe von React und einer clearHover-Klasse erreichen.

// Menu.jsx
const Menu = (props) => {
  const { onSelectMenuItem } = props;
  const [clearHover, setClearHover] = React.useState(false);

  const closeMenu= () => {
    onSelectMenuItem();
    setClearHover(true);
  }

  React.useEffect(() => {
    let timeout;
    if (clearHover) {
      timeout = setTimeout(() => {
        setClearHover(false);
      }, 0); // Adjust this timeout to suit the applications' needs
    }
    return () => clearTimeout(timeout);
  }, [clearHover]);

  return (
    <ul className={`menu ${clearHover ? "clearHover" : ""}`}>
      <li className="menu-item">
        <Link to="/" onClick={closeMenu}>Home</Link>
      </li>
      <li className="menu-item">
        <Link to="/products" onClick={closeMenu}>Products</Link>
        <ul className="sub-menu">
          {/* Sub Menu Items */}
        </ul>
      </li>
    </ul>
  );
}

Dieser aktualisierte Code blendet das Menü sofort aus, wenn ein Menüpunkt angeklickt wird. Er blendet es auch sofort aus, wenn ein Tastaturnutzer einen Menüpunkt auswählt. Das Drücken der Tabulatortaste nach der Auswahl eines Navigationslinks verschiebt den Fokus auf den "Zum Inhalt springen"-Link.

Zu diesem Zeitpunkt hatte unser Team die Menükomponente so weit aktualisiert, dass wir sehr zufrieden waren. Sowohl Tastatur- als auch Mausanwender erhalten eine konsistente Erfahrung, und diese Erfahrung folgt dem, was ein Browser standardmäßig bei einem vollständigen Seitenreload tut.

Unsere tatsächliche Implementierung unterscheidet sich geringfügig von dem hier gezeigten Beispiel, damit wir das Muster auch auf anderen Projekten verwenden können. Wir haben es in einen React Context gelegt, wobei der Provider die Header-Komponente umschließt und das Focus Reset-Element automatisch kurz vor den children des Providers hinzugefügt wird. Auf diese Weise wird das Element vor dem "Zum Inhalt springen"-Link in der DOM-Hierarchie platziert. Es ermöglicht uns auch, auf die Fokus-Reset-Funktion mit einem einfachen Hook zuzugreifen, anstatt sie durch Props übergeben zu müssen.

Wir haben eine Code Sandbox erstellt, mit der Sie mit den drei hier behandelten Lösungen experimentieren können. Sie werden die Schwachstellen der früheren Implementierung definitiv erkennen und dann sehen, wie viel besser sich das Endergebnis anfühlt!

Wir würden uns sehr über Feedback zu dieser Implementierung freuen! Wir denken, dass sie gut funktionieren wird, aber sie wurde noch nicht in freier Wildbahn veröffentlicht, daher haben wir noch keine definitiven Daten oder Benutzer-Feedback. Wir sind sicherlich keine a11y-Experten, wir tun nur unser Bestes mit dem, was wir *wissen*, und sind sehr offen und bereit, mehr zu diesem Thema zu lernen.