Erstellen eines barrierefreien Dialogfelds von Grund auf neu

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ Dialoge sind allgegenwärtig im modernen Interface-Design (im Guten wie im Schlechten), und doch sind viele von ihnen für unterstützende Technologien nicht zugänglich. In diesem Artikel werden wir uns damit befassen, wie man ein kurzes Skript erstellt, um barrierefreie Dialoge zu erstellen.

Erstens, tun Sie dies nicht zu Hause. Schreiben Sie dazu keine eigenen Dialoge oder Bibliotheken. Es gibt bereits viele davon, die getestet, geprüft, verwendet und wiederverwendet wurden, und Sie sollten diese Ihren eigenen vorziehen. a11y-dialog ist einer davon, aber es gibt noch mehr (aufgelistet am Ende dieses Artikels).

Lassen Sie mich diesen Beitrag zum Anlass nehmen , Sie alle daran zu erinnern , beim Umgang mit Dialogen vorsichtig zu sein . Es ist verlockend, alle Designprobleme damit zu lösen, insbesondere auf Mobilgeräten, aber es gibt oft andere Möglichkeiten, Designprobleme zu lösen. Wir neigen schnell dazu, Dialoge zu verwenden, nicht weil sie unbedingt die richtige Wahl sind, sondern weil sie einfach sind. Sie beseitigen Bildschirmprobleme, indem sie sie gegen Kontextwechsel eintauschen, was nicht immer der richtige Kompromiss ist. Der Punkt ist: Überlegen Sie, ob ein Dialog das richtige Entwurfsmuster ist, bevor Sie ihn verwenden.

In diesem Beitrag schreiben wir eine kleine JavaScript-Bibliothek zum Erstellen barrierefreier Dialoge von Anfang an (im Wesentlichen die Neuerstellung eines 11y-Dialogs). Das Ziel ist zu verstehen, was darin enthalten ist. Wir werden uns nicht zu sehr mit dem Styling befassen, sondern nur mit dem JavaScript-Teil. Der Einfachheit halber verwenden wir modernes JavaScript (z. B. Klassen und Pfeilfunktionen), aber denken Sie daran, dass dieser Code in älteren Browsern möglicherweise nicht funktioniert.

  1. Definieren der API
  2. Instanziieren des Dialogs
  3. Zeigen und Verstecken
  4. Schließen mit Overlay
  5. Schließen mit Flucht
  6. Einfangen des Fokus
  7. Fokus beibehalten
  8. Fokus wiederherstellen
  9. Geben Sie einen zugänglichen Namen
  10. Umgang mit benutzerdefinierten Ereignissen
  11. Aufräumen
  12. Bringen Sie alles zusammen
  13. Einpacken

Definieren der API

Zuerst wollen wir definieren, wie wir unser Dialogskript verwenden werden. Wir werden es zu Beginn so einfach wie möglich halten. Wir geben ihm das Root-HTML-Element für unseren Dialog, und die Instanz, die wir erhalten, hat eine .show(..) und eine .hide(..) Methode.

 class Dialog { constructor(element) {} show() {} hide() {} }

Instanziieren des Dialogs

Nehmen wir an, wir haben den folgenden HTML-Code:

 <div>This will be a dialog.</div>

Und wir instanziieren unseren Dialog wie folgt:

 const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)

Es gibt ein paar Dinge, die wir unter der Haube tun müssen, wenn wir es instanziieren:

  • Verstecke es, damit es standardmäßig ausgeblendet ist ( hidden ).
  • Als Dialog für Hilfstechnologien markieren ( role="dialog" ).
  • Machen Sie den Rest der Seite inaktiv, wenn sie geöffnet ist ( aria-modal="true" ).
 constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true) }

Beachten Sie, dass wir diese 3 Attribute in unserem ursprünglichen HTML-Code hätten hinzufügen können, um sie nicht mit JavaScript hinzufügen zu müssen, aber auf diese Weise ist es aus den Augen, aus dem Sinn. Unser Skript kann sicherstellen, dass die Dinge so funktionieren, wie sie sollten, unabhängig davon, ob wir daran gedacht haben, alle unsere Attribute hinzuzufügen oder nicht.

Ein- und Ausblenden

Wir haben zwei Methoden: eine zum Anzeigen des Dialogs und eine zum Ausblenden. Diese Methoden werden (vorerst) nicht viel bewirken, außer das hidden Attribut des Root-Elements umzuschalten. Wir werden auch einen booleschen Wert für die Instanz beibehalten, um schnell beurteilen zu können, ob der Dialog angezeigt wird oder nicht. Dies wird sich später als nützlich erweisen.

 show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }

Um zu vermeiden, dass der Dialog sichtbar ist, bevor JavaScript einsetzt und ihn durch Hinzufügen des Attributs hidden , kann es interessant sein, den Dialog von Anfang an direkt im HTML-Code auszublenden.

 <div hidden>This will be a dialog.</div>

Schließen mit Overlay

Wenn Sie außerhalb des Dialogfelds klicken, sollte es geschlossen werden. Dazu gibt es mehrere Möglichkeiten. Eine Möglichkeit könnte darin bestehen, alle Klickereignisse auf der Seite abzuhören und diejenigen herauszufiltern, die innerhalb des Dialogs stattfinden, aber das ist relativ komplex.

Ein anderer Ansatz wäre das Abhören von Klickereignissen auf dem Overlay (manchmal auch als „Backdrop“ bezeichnet). Das Overlay selbst kann bei einigen Stilen so einfach wie ein <div> sein.

Wenn wir also den Dialog öffnen, müssen wir Klickereignisse an das Overlay binden. Wir könnten ihm eine ID oder eine bestimmte Klasse geben, um ihn abfragen zu können, oder wir könnten ihm ein Datenattribut geben. Ich neige dazu, diese für Verhaltenshaken zu bevorzugen. Ändern wir unser HTML entsprechend:

 <div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>

Jetzt können wir die Elemente mit dem Attribut data-dialog-hide innerhalb des Dialogs abfragen und ihnen einen Click-Listener geben, der den Dialog verbirgt.

 constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) }

Das Schöne daran, etwas ganz Allgemeines wie dieses zu haben, ist, dass wir dasselbe auch für die Schließen-Schaltfläche des Dialogs verwenden können.

 <div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Schließen mit Escape

Der Dialog sollte nicht nur ausgeblendet werden, wenn Sie außerhalb davon klicken, sondern er sollte auch ausgeblendet werden, wenn Sie Esc drücken. Beim Öffnen des Dialogs können wir einen Tastatur-Listener an das Dokument binden und ihn beim Schließen entfernen. Auf diese Weise hört es nur auf Tastendrücke, während der Dialog geöffnet ist, anstatt die ganze Zeit.

 show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown) } hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() }

Einfangen des Fokus

Das ist jetzt das Gute. Das Einfangen des Fokus innerhalb des Dialogs ist sozusagen das Wesentliche der ganzen Sache und muss der komplizierteste Teil sein (obwohl wahrscheinlich nicht so kompliziert, wie Sie vielleicht denken).

Die Idee ist ziemlich einfach: Wenn der Dialog geöffnet ist, hören wir auf das Drücken der Tabulatortaste . Wenn Sie beim letzten fokussierbaren Element des Dialogfelds die Tabulatortaste drücken, verschieben wir den Fokus programmgesteuert auf das erste. Wenn Sie Shift + Tab auf dem ersten fokussierbaren Element des Dialogs drücken, verschieben wir es zum letzten.

Die Funktion könnte so aussehen:

 function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } }

Als nächstes müssen wir herausfinden, wie wir alle fokussierbaren Elemente des Dialogs erhalten ( getFocusableChildren ). Wir müssen alle Elemente abfragen, die theoretisch fokussierbar sein können, und dann müssen wir sicherstellen, dass sie es tatsächlich sind.

Der erste Teil kann mit fokussierbaren Selektoren durchgeführt werden. Es ist ein winziges Paket, das ich geschrieben habe und das dieses Array von Selektoren bereitstellt:

 module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])', ]

Und das reicht aus, um Sie zu 99% dorthin zu bringen. Wir können diese Selektoren verwenden, um alle fokussierbaren Elemente zu finden, und dann können wir jedes einzelne davon überprüfen, um sicherzustellen, dass es tatsächlich auf dem Bildschirm sichtbar ist (und nicht versteckt oder so).

 import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }

Wir können jetzt unsere handleKeyDown Methode aktualisieren:

 handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }

Fokus aufrechterhalten

Eine Sache, die beim Erstellen barrierefreier Dialoge oft übersehen wird, ist sicherzustellen, dass der Fokus innerhalb des Dialogs bleibt, auch wenn die Seite den Fokus verloren hat. Stellen Sie sich das so vor: Was passiert, wenn der Dialog einmal geöffnet ist? Wir fokussieren die URL-Leiste des Browsers und beginnen dann erneut mit der Tabulatortaste. Unsere Fokusfalle wird nicht funktionieren, da sie den Fokus innerhalb des Dialogs nur behält, wenn er sich von Anfang an innerhalb des Dialogs befindet.

Um dieses Problem zu beheben, können wir einen Fokus-Listener an das <body> -Element binden, wenn der Dialog angezeigt wird, und den Fokus auf das erste fokussierbare Element innerhalb des Dialogs verschieben.

 show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true) } hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true) } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() }

Welches Element beim Öffnen des Dialogfelds fokussiert werden soll, wird nicht erzwungen und kann davon abhängen, welche Art von Inhalt das Dialogfeld anzeigt. Generell gibt es ein paar Möglichkeiten:

  • Fokussieren Sie das erste Element.
    Dies tun wir hier, da es dadurch einfacher wird, dass wir bereits eine getFocusableChildren Funktion haben.
  • Fokussieren Sie die Schließen-Schaltfläche.
    Dies ist auch eine gute Lösung, besonders wenn der Button absolut relativ zum Dialog positioniert ist. Wir können dies bequem erreichen, indem wir unseren Schließen-Button als erstes Element unseres Dialogs platzieren. Wenn die Schließen-Schaltfläche ganz am Ende im Fluss des Dialoginhalts lebt, könnte es ein Problem sein, wenn der Dialog viel Inhalt hat (und daher scrollbar ist), da er den Inhalt beim Öffnen bis zum Ende scrollen würde.
  • Konzentrieren Sie den Dialog selbst .
    Dies ist bei Dialogbibliotheken nicht sehr üblich, sollte aber auch funktionieren (obwohl es das Hinzufügen von tabindex="-1" erfordern würde, damit dies möglich ist, da ein <div> -Element standardmäßig nicht fokussierbar ist).

Beachten Sie, dass wir prüfen, ob es ein Element mit dem HTML-Attribut autofocus innerhalb des Dialogs gibt, in diesem Fall würden wir den Fokus darauf statt auf das erste Element verschieben.

Fokus wiederherstellen

Wir haben es geschafft, den Fokus erfolgreich innerhalb des Dialogs zu fangen, aber wir haben vergessen, den Fokus innerhalb des Dialogs zu verschieben, sobald er geöffnet wird. Ebenso müssen wir den Fokus wieder auf das Element wiederherstellen, das ihn hatte, bevor der Dialog geöffnet wurde.

Beim Anzeigen des Dialogs können wir beginnen, indem wir einen Verweis auf das Element beibehalten, das den Fokus hat ( document.activeElement ). Meistens ist dies die Schaltfläche, mit der zum Öffnen des Dialogfelds interagiert wurde, aber in seltenen Fällen, in denen ein Dialogfeld programmgesteuert geöffnet wird, kann es sich um etwas anderes handeln.

 show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }

Beim Ausblenden des Dialogs können wir den Fokus wieder auf dieses Element verschieben. Wir schützen es mit einer Bedingung, um einen JavaScript-Fehler zu vermeiden, wenn das Element irgendwie nicht mehr existiert (oder wenn es ein SVG war):

 hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }

Einen zugänglichen Namen geben

Es ist wichtig, dass unser Dialog einen zugänglichen Namen hat, so wird er in der Barrierefreiheitsstruktur aufgelistet. Es gibt mehrere Möglichkeiten, dies zu beheben, von denen eine darin besteht, einen Namen im Attribut aria- aria-label zu definieren, aber aria-label hat Probleme.

Eine andere Möglichkeit besteht darin, einen Titel in unserem Dialog zu haben (ob versteckt oder nicht) und unseren Dialog damit mit dem Attribut aria-labelledby zu verknüpfen. Es könnte so aussehen:

 <div hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1>My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>

Ich schätze, wir könnten unser Skript dazu bringen, dieses Attribut dynamisch anzuwenden, basierend auf dem Vorhandensein des Titels und so weiter, aber ich würde sagen, dass dies genauso einfach gelöst werden kann, indem zunächst das richtige HTML erstellt wird. Dafür muss kein JavaScript hinzugefügt werden.

Umgang mit benutzerdefinierten Ereignissen

Was ist, wenn wir darauf reagieren wollen, dass der Dialog geöffnet ist? Oder geschlossen? Derzeit gibt es keine Möglichkeit, aber das Hinzufügen eines kleinen Ereignissystems sollte nicht allzu schwierig sein. Wir brauchen eine Funktion zum Registrieren von Ereignissen (nennen wir sie .on(..) ) und eine Funktion zum Aufheben der Registrierung ( .off(..) ).

 class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } }

Beim Ein- und Ausblenden der Methode rufen wir dann alle Funktionen auf, die für dieses bestimmte Ereignis registriert wurden.

 class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }

Aufräumen

Wir möchten vielleicht eine Methode bereitstellen, um einen Dialog zu bereinigen, falls wir damit fertig sind. Es wäre dafür verantwortlich, Ereignis-Listener abzumelden, damit sie nicht länger dauern, als sie sollten.

 class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } }

Alles zusammenbringen

 import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } } function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } } function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }

Einpacken

Das war schon etwas, aber wir haben es schließlich geschafft! Auch hier würde ich davon abraten, eine eigene Dialogbibliothek einzuführen, da dies nicht die einfachste ist und Fehler für Benutzer von Hilfstechnologien sehr problematisch sein können. Aber zumindest wissen Sie jetzt, wie es unter der Haube funktioniert!

Wenn Sie in Ihrem Projekt Dialoge verwenden müssen, ziehen Sie die Verwendung einer der folgenden Lösungen in Betracht (bitte erinnern Sie sich daran, dass wir auch unsere umfassende Liste zugänglicher Komponenten haben):

  • Vanilla-JavaScript-Implementierungen: a11y-dialog von Ihnen oder aria-modal-dialog von Scott O'Hara.
  • React-Implementierungen: React-a11y-dialog von mir, Reach/Dialog aus dem Reach-Framework oder @react-aria/dialog von Adobe. Dieser Vergleich der 3 Bibliotheken könnte Sie interessieren.
  • Vue-Implementierungen: vue-a11y-dialog von Moritz Kroger, a11y-vue-dialog von Renato de Leao.

Hier sind weitere Dinge, die hinzugefügt werden könnten, aber der Einfachheit halber nicht gemacht wurden:

  • Unterstützung für Alert-Dialoge über die Rolle alertdialog . Weitere Informationen zu Warndialogen finden Sie in der a11y-dialog-Dokumentation.
  • Sperren der Fähigkeit zum Scrollen, während der Dialog geöffnet ist. Siehe die a11y-dialog-Dokumentation zur Scroll-Sperre.
  • Unterstützung für das native HTML- <dialog> -Element, da es unterdurchschnittlich und inkonsistent ist. Weitere Informationen darüber, warum sich die Mühe nicht lohnt, finden Sie in der a11y-dialog-Dokumentation zum Dialogelement und in diesem Artikel von Scott O'hara.
  • Unterstützung für verschachtelte Dialoge, weil es fragwürdig ist. Weitere Informationen zu verschachtelten Dialogen finden Sie in der a11y-dialog-Dokumentation.
  • Überlegung zum Schließen des Dialogs zur Browsernavigation. In manchen Fällen kann es sinnvoll sein, den Dialog beim Drücken der Zurück-Taste des Browsers zu schließen.