Crearea unui dialog accesibil de la zero

Publicat: 2022-03-10
Rezumat rapid ↬ Dialogurile sunt peste tot în designul modern al interfeței (în bine sau în rău) și totuși multe dintre ele nu sunt accesibile tehnologiilor de asistență. În acest articol, vom cerceta cum să creați un script scurt pentru a crea dialoguri accesibile.

În primul rând, nu face asta acasă. Nu scrieți propriile dialoguri sau o bibliotecă pentru a face acest lucru. Există deja o mulțime de ele care au fost testate, auditate, utilizate și refolosite și ar trebui să le preferați pe acestea decât pe ale dvs. a11y-dialog este unul dintre ele, dar sunt mai multe (enumerate la sfârșitul acestui articol).

Permiteți-mi să iau această postare ca pe o oportunitate de a vă reamintește tuturor să fiți precauți când utilizați casetele de dialog . Este tentant să abordăm toate problemele de design cu acestea, în special pe mobil, dar există adesea și alte modalități de a depăși problemele de design. Avem tendința de a cădea rapid în utilizarea dialogurilor nu pentru că sunt neapărat alegerea potrivită, ci pentru că sunt ușoare. Ei pun deoparte problemele cu patrimoniul ecranului schimbându-le pentru schimbarea contextului, ceea ce nu este întotdeauna un compromis potrivit. Ideea este: luați în considerare dacă un dialog este modelul de design potrivit înainte de a-l folosi.

În această postare, vom scrie o bibliotecă JavaScript mică pentru a crea dialoguri accesibile încă de la început (recreând, în esență, a11y-dialog). Scopul este să înțelegem ce se întâmplă în el. Nu ne vom ocupa prea mult de stil, ci doar de partea JavaScript. Vom folosi JavaScript modern din motive de simplitate (cum ar fi clasele și funcțiile de săgeți), dar rețineți că acest cod ar putea să nu funcționeze în browserele vechi.

  1. Definirea API-ului
  2. Instanțierea dialogului
  3. Arată și ascunde
  4. Închidere cu suprapunere
  5. Închiderea cu evadare
  6. Focalizare capcană
  7. Menținerea concentrării
  8. Restabilirea focalizării
  9. Acordarea unui nume accesibil
  10. Gestionarea evenimentelor personalizate
  11. A curăța
  12. Aduceți totul împreună
  13. Încheierea

Definirea API-ului

În primul rând, dorim să definim cum vom folosi scriptul nostru de dialog. Pentru început, o vom păstra cât mai simplă. Îi dăm elementul HTML rădăcină pentru dialogul nostru, iar instanța pe care o obținem are o .show(..) și .hide(..) .

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

Instanțiarea Dialogului

Să presupunem că avem următorul HTML:

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

Și instanțiem dialogul nostru astfel:

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

Există câteva lucruri pe care trebuie să le facem sub capotă atunci când o instanțiem:

  • Ascundeți-l astfel încât să fie ascuns în mod implicit ( hidden ).
  • Marcați-l ca dialog pentru tehnologii de asistență ( role="dialog" ).
  • Faceți restul paginii inert atunci când este deschisă ( 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) }

Rețineți că am fi putut adăuga aceste 3 atribute în HTML-ul nostru inițial pentru a nu fi nevoiți să le adăugăm cu JavaScript, dar în acest fel este departe de vedere, din minte. Scriptul nostru se poate asigura că lucrurile vor funcționa așa cum ar trebui, indiferent dacă ne-am gândit să adăugăm toate atributele noastre sau nu.

Arătarea și ascunderea

Avem două metode: una pentru a afișa dialogul și alta pentru a-l ascunde. Aceste metode nu vor face mare lucru (deocamdată) în afară de a comuta atributul hidden pe elementul rădăcină. De asemenea, vom menține un boolean pe instanță pentru a putea evalua rapid dacă dialogul este afișat sau nu. Acest lucru va fi util mai târziu.

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

Pentru a evita ca dialogul să fie vizibil înainte ca JavaScript să se activeze și să-l ascunde prin adăugarea atributului, ar putea fi interesant să adăugați dialog hidden direct în HTML de la început.

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

Închidere cu suprapunere

Făcând clic în afara casetei de dialog ar trebui să-l închidă. Există mai multe moduri de a face acest lucru. O modalitate ar putea fi să ascultați toate evenimentele de clic de pe pagină și să le filtrați pe cele care au loc în dialog, dar acest lucru este relativ complex de făcut.

O altă abordare ar fi să ascultați evenimentele de clic pe suprapunere (numite uneori „fondul”). Suprapunerea în sine poate fi la fel de simplă ca un <div> cu unele stiluri.

Deci, când deschidem dialogul, trebuie să legăm evenimentele de clic pe suprapunere. I-am putea da un ID sau o anumită clasă pentru a-l putea interoga sau i-am putea da un atribut de date. Tind să le prefer pe acestea pentru cârlige de comportament. Să modificăm HTML-ul în consecință:

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

Acum, putem interoga elementele cu atributul data-dialog-hide din dialog și le putem oferi un ascultător de clic care ascunde dialogul.

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

Lucrul frumos despre a avea ceva destul de generic ca acesta este că putem folosi același lucru și pentru butonul de închidere al casetei de dialog.

 <div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Mai multe după săritură! Continuați să citiți mai jos ↓

Închiderea Cu Evadare

Nu numai că caseta de dialog ar trebui să fie ascunsă atunci când faceți clic în afara acesteia, dar ar trebui să fie și atunci când apăsați Esc . Când deschidem dialogul, putem lega un ascultător de tastatură la document și îl putem elimina când îl închidem. În acest fel, ascultă doar apăsările de taste în timp ce dialogul este deschis, nu tot timpul.

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

Focalizarea capcanei

Acum astea sunt lucrurile bune. Captarea focalizării în dialog este un fel de esența întregului lucru și trebuie să fie cea mai complicată parte (deși probabil nu atât de complicată pe cât ați putea crede).

Ideea este destul de simplă: când dialogul este deschis, ascultăm apăsările Tab . Dacă apăsăm Tab pe ultimul element focalizat al dialogului, mutăm focalizarea pe primul în mod programatic. Dacă apăsăm Shift + Tab pe primul element focalizat al dialogului, îl mutăm la ultimul.

Funcția ar putea arăta astfel:

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

Următorul lucru pe care trebuie să-l dăm seama este cum să obținem toate elementele focalizabile ale dialogului ( getFocusableChildren ). Trebuie să interogăm toate elementele care teoretic pot fi focalizabile și apoi trebuie să ne asigurăm că sunt efectiv.

Prima parte se poate face cu selectoare focalizabile. Este un pachet minuscul pe care l-am scris, care oferă această serie de selectori:

 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^="-"])', ]

Și acest lucru este suficient pentru a ajunge la 99% acolo. Putem folosi acești selectoare pentru a găsi toate elementele focalizabile și apoi putem verifica fiecare dintre ele pentru a ne asigura că este de fapt vizibil pe ecran (și nu ascuns sau ceva de genul ăsta).

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

Acum putem actualiza metoda handleKeyDown :

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

Menținerea concentrării

Un lucru care este adesea trecut cu vederea atunci când creați dialoguri accesibile este să vă asigurați că focalizarea rămâne în dialog chiar și după ce pagina și-a pierdut focalizarea. Gândiți-vă la asta astfel: ce se întâmplă dacă odată ce dialogul este deschis? Concentrăm bara de adrese URL a browserului și apoi începem din nou să facem file. Capcana noastră de focalizare nu va funcționa, deoarece păstrează focalizarea în dialog doar atunci când este în interiorul dialogului pentru început.

Pentru a remedia această problemă, putem lega un ascultător de focalizare la elementul <body> atunci când este afișat dialogul și putem muta focalizarea pe primul element focalizat din dialog.

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

Ce element să se concentreze la deschiderea dialogului nu este impus și poate depinde de tipul de conținut pe care îl afișează dialogul. În general, există câteva opțiuni:

  • Concentrează primul element.
    Aceasta este ceea ce facem aici, deoarece este facilitat de faptul că avem deja o funcție getFocusableChildren .
  • Focalizează butonul de închidere.
    Aceasta este, de asemenea, o soluție bună, mai ales dacă butonul este poziționat absolut relativ la dialog. Putem face acest lucru să se întâmple, plasând butonul de închidere ca prim element al dialogului nostru. Dacă butonul de închidere se află în fluxul conținutului dialogului, la sfârșit, ar putea fi o problemă dacă dialogul are mult conținut (și, prin urmare, poate fi derulat), deoarece ar derula conținutul până la sfârșit la deschidere.
  • Focalizează dialogul în sine .
    Acest lucru nu este foarte comun printre bibliotecile de dialog, dar ar trebui să funcționeze și (deși ar necesita adăugarea tabindex="-1" la acesta, astfel încât acest lucru este posibil, deoarece un element <div> nu este focalizat în mod implicit).

Rețineți că verificăm dacă există un element cu atributul HTML autofocus în cadrul casetei de dialog, caz în care am muta focusul pe acesta în loc de primul element.

Restabilirea focalizării

Am reușit să captăm cu succes focalizarea în dialog, dar am uitat să mutăm focalizarea în interiorul dialogului odată ce acesta se deschide. În mod similar, trebuie să restabilim focalizarea pe elementul care o avea înainte ca dialogul să fie deschis.

Când afișăm dialogul, putem începe prin a păstra o referință la elementul care are focalizarea ( document.activeElement ). De cele mai multe ori, acesta va fi butonul cu care a fost interacționat pentru a deschide dialogul, dar în cazuri rare în care un dialog este deschis programatic, ar putea fi altceva.

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

Când ascundem dialogul, putem muta focalizarea înapoi la acel element. Îl păzim cu o condiție de a evita o eroare JavaScript dacă elementul cumva nu mai există (sau dacă a fost un SVG):

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

Acordarea unui nume accesibil

Este important ca dialogul nostru să aibă un nume accesibil, care este modul în care va fi listat în arborele de accesibilitate. Există câteva moduri de a rezolva problema, dintre care una este definirea unui nume în atributul aria-label , dar aria-label are probleme.

O altă modalitate este de a avea un titlu în dialogul nostru (ascuns sau nu) și de a asocia dialogul nostru cu atributul aria-labelledby . Ar putea arăta astfel:

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

Bănuiesc că am putea face ca scriptul nostru să aplice acest atribut în mod dinamic pe baza prezenței titlului și altele, dar aș spune că acest lucru este la fel de ușor de rezolvat prin crearea unui HTML adecvat, pentru început. Nu este nevoie să adăugați JavaScript pentru asta.

Gestionarea evenimentelor personalizate

Ce se întâmplă dacă vrem să reacționăm la deschiderea dialogului? Sau închis? În prezent, nu există nicio modalitate de a face acest lucru, dar adăugarea unui mic sistem de evenimente nu ar trebui să fie prea dificilă. Avem nevoie de o funcție pentru a înregistra evenimente (să o numim .on(..) ) și de o funcție pentru a le anula înregistrarea ( .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) } }

Apoi, când afișăm și ascundem metoda, vom apela toate funcțiile care au fost înregistrate pentru acel eveniment anume.

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

A curăța

S-ar putea să dorim să furnizăm o metodă pentru a curăța un dialog în cazul în care nu-l mai folosim. Ar fi responsabil pentru anularea înregistrării ascultătorilor de evenimente, astfel încât aceștia să nu reziste mai mult decât ar trebui.

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

Aducând totul împreună

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

Încheierea

A fost destul de ceva, dar în cele din urmă am ajuns acolo! Încă o dată, aș sfătui să nu vă lansați propria bibliotecă de dialog, deoarece nu este cea mai simplă și erorile ar putea fi extrem de problematice pentru utilizatorii de tehnologie de asistență. Dar măcar acum știi cum funcționează sub capotă!

Dacă trebuie să utilizați casete de dialog în proiectul dvs., luați în considerare utilizarea uneia dintre următoarele soluții (reamintiți bine că avem și lista noastră cuprinzătoare de componente accesibile):

  • Implementări JavaScript Vanilla: a11y-dialog de către dvs. cu adevărat sau aria-modal-dialog de Scott O'Hara.
  • Implementări React: react-a11y-dialog cu adevărat din nou, reach/dialog din cadrul Reach sau @react-aria/dialog de la Adobe. S-ar putea să fiți interesat de această comparație a celor 3 biblioteci.
  • Implementări Vue: vue-a11y-dialog de Moritz Kroger, a11y-vue-dialog de Renato de Leao.

Iată mai multe lucruri care ar putea fi adăugate, dar nu au fost de dragul simplității:

  • Suport pentru dialoguri de alertă prin rolul alertdialog . Consultați documentația a11y-dialog despre dialogurile de alertă.
  • Blocarea capacității de a derula în timp ce dialogul este deschis. Consultați documentația din dialogul a11y privind blocarea derulării.
  • Suport pentru elementul <dialog> HTML nativ, deoarece este sub egal și inconsecvent. Consultați documentația a11y-dialog despre elementul de dialog și această piesă de Scott O'hara pentru mai multe informații despre motivul pentru care nu merită problemele.
  • Suport pentru dialoguri imbricate, deoarece este discutabil. Consultați documentația a11y-dialog despre dialogurile imbricate.
  • Luare în considerare pentru închiderea casetei de dialog privind navigarea în browser. În unele cazuri, ar putea avea sens să închideți dialogul atunci când apăsați butonul înapoi al browserului.