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
hrefist aufjavascript: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.tabIndexist auf-1gesetzt, 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.styleverschiebt das Element einfach aus dem Viewport. Das Setzen aufposition: fixedstellt sicher, dass es aus dem Dokumentfluss entfernt wird, sodass kein vertikaler Platz für das Element zugewiesen wird.aria-hiddenteilt 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.
Es ist großartig zu sehen, dass an barrierefreier SPA-Navigation gearbeitet wird, also danke dafür!
Ich habe Ihren CodeSandBox schnell in Firefox/NVDA und Safari/VoiceOver (Mac) getestet. Nach der Aktivierung des Routenlinks erfolgte die Fokusänderung (ich habe sie mit document.addEventListener(‘focusin’, () => {console.log(document.activeElement);}); verfolgt), aber da Sie den Fokus auf ein aria-hidden-Element senden, blieben beide Screenreader stumm.
Aber Sie sind auf einem guten Weg, den Fokus nach dem Routenübergang auf ein Element zu lenken. Marcy Sutton hat ein ähnliches Muster erforscht: https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
Hallo Marcus!
Danke für den Link zum Artikel, den werde ich mir auf jeden Fall ansehen. Wir hatten Schwierigkeiten, solche Artikel online zu finden, als wir das Menü implementiert haben, also ist es gut zu wissen, dass es welche gibt!
Etwas enttäuscht von meiner ersten Interaktion mit dem Menü auf einem Android 11 Smartphone. Wenn ich auf "Produkte" tippe, ist das Untermenü nur so lange sichtbar, wie mein Finger auf dem Bildschirm ist. Sobald ich den Finger hebe, werde ich zur Seite "Produkte" weitergeleitet. Grundsätzlich kann ich nie auf ein Untermenü tippen, es sei denn, ich mache verrückte Dinge wie einen Finger auf "Produkte" zu halten, während ich mit einem zweiten Finger auf einen Link im Untermenü fokussiere.
Hallo Sebastian, danke für das Feedback!
Für unsere App verwenden wir dieses Layout auf Mobilgeräten tatsächlich nicht, wir rendern die Menüpunkte anders, mit Touch-Interaktionen, wie man es auf Mobilgeräten erwarten würde.
Dieses Menü ist für größere Geräte und Geräte gedacht, die typischerweise eine Tastatureingabe haben.
Vielleicht hätten wir diese Klausel am Ende des Artikels hinzufügen sollen, danke, dass Sie darauf hingewiesen haben.
Um die Bandbreite der Browser zu verbessern, die das Menü wie beabsichtigt sehen können, müssen Sie möglicherweise den Selektor
:focus-withinvon den anderen Selektoren trennen. Da ein Browser alle durch Komma getrennten Selektoren ignoriert, wenn er EINEN davon nicht versteht.Richtig, richtig
Absolut richtig, Clint!
Dies war das erste Feedback, das Chris gab, als wir die Entwürfe des Artikels durchgingen.
Aus Gründen der Kürze war ich bestrebt, sie zusammenzufassen, aber ich hätte auf jeden Fall auf die Einschränkung dieses Ansatzes hinweisen sollen, daher vielen Dank für Ihre Veröffentlichung.
Großartig!!!
Können Sie bitte die Website-URL mitteilen, an der Sie gearbeitet haben?
Das wäre großartig für Lernende wie mich..
Vielen Dank im Voraus
Ich habe einige gute Erkenntnisse aus diesem Artikel gewonnen, aber ich stimme Ihren Entscheidungen, wohin der Fokus gelenkt werden soll, nicht zu. Lassen Sie mich meine Erfahrungen teilen.
Sicher, man kann jedem Element eine tabindex zuweisen, aber jedes Element, das den Fokus erhält, muss eine zugängliche Bezeichnung haben. Den Benutzer ins Nichts zu schicken und zu erwarten, dass er erneut mit Tabulatortasten navigiert, um seinen Platz auf der Seite zu finden, ist weder ideal noch akzeptabel.
Warum eine vollständige Seitenavigation nachahmen? Ohne einen vollständigen Seitenaufruf verhält sich auch der Screenreader nicht gleich. Er liest nicht den Seitentitel erneut wie bei einem vollständigen Seitenaufruf. Der Benutzer muss irgendwie herausfinden, was sich geändert hat, und ihn ganz oben auf der Seite hinzuschicken, hilft diesem Ziel nicht. Schicken Sie ihn zu dem Inhalt, der aktualisiert wurde.
Zu diesem Punkt, was meiner Erfahrung nach in SPAs gut funktioniert, ist, das Tag für sowohl den Skip-Nav-Link als auch jeden Router-Nav (vorausgesetzt, es gibt nur einen Router-Outlet für den Hauptinhalt in diesem Fall) anzuvisieren. Sie müssen ihm immer noch eine tabindex zuweisen. Angenommen, Ihr Router-Outlet befindet sich direkt darin, ist das Haupt-Element aus mehreren Gründen ideal:
a) es ist statisch und umschließt Ihren Router-Outlet, sodass Sie sich keine Gedanken über asynchrone Race Conditions beim Senden des Fokus machen müssen und
b) es ist ein Landmark-Element und wird von JAWS entsprechend angesagt. Wenn es sich um ein div ohne zugängliche Bezeichnung handeln würde, würde ein Screenreader mit dem Lesen aller Inhalte beginnen, was ebenfalls nicht ideal ist.
Sie können dem main-Tag ein aria-label oder aria-describedby für mehr Kontext hinzufügen, aber die genauen Interaktionen erinnere ich mich jetzt nicht mehr.
Es sollte "main"-Tag heißen. Sieht aus, als hätte es das verschluckt.
Ich bin mir auch nicht sicher, warum Sie das Gefühl eines vollständigen Seitenaufrufs für Personen, die auf Tastaturnavigation angewiesen sind, nachahmen möchten, insbesondere da es einen echten Vorteil darin gibt, den Navigationszustand so zu belassen, wie er ist.
Betrachten Sie die Navigation über die Tabulatortaste durch https://twitter.com/; Sie können von Benachrichtigungen zu Nachrichten navigieren. Ihr Menü bleibt gleich. Das ist eine viel bessere Benutzererfahrung.
Für Benutzer, die auf einen Screenreader angewiesen sind, möchten Sie jedoch eine Möglichkeit haben, anzukündigen, dass Sie sich auf einer neuen Seite befinden. Hier kommt ein "Announcer" ins Spiel, der aria-live verwendet.
Die oben erwähnte Recherche von Marcy Sutton hat eine Fortsetzung in diesem Artikel: https://www.gatsbyjs.com/blog/2020-02-10-accessible-client-side-routing-improvements/
Es ist eine bewährte Methode,
left: -100000px;zu vermeiden, um etwas vom Bildschirm zu verbannen. Für internationale Benutzer, die möglicherweise auf automatische Seitenübersetzung angewiesen sind, kann dies eine Scrollleiste erzeugen.Ändern Sie beispielsweise in Ihrem Code
<html lang="en">zu<html lang="ur" dir="rtl">und beachten Sie die angezeigte Scrollleiste (Leser können dies auch in den Browser-Entwicklertools tun). Selbst wenn Sie zu CSS Logical Properties wechseln, kann dies immer noch ein Problem darstellen.Um etwas barrierefrei zu verstecken und internationale Probleme zu vermeiden, werfen Sie einen Blick auf Kitty Giraudels Hiding Content Responsibly oder Scott O’Haras Inclusively Hidden.
Eine robustere Lösung zur Verwaltung des Fokus bei clientseitiger Navigation ist das Hinzufügen von
tabindex="-1"zubodyund das Aufrufen vondocument.body.focus()nach einer Routenänderung.tabindex="-1"fügt kein Element zur Tabulatorreihenfolge hinzu, ermöglicht ihm jedoch, den Fokus programmatisch zu erhalten.