Creazione di una finestra di dialogo accessibile da zero
Pubblicato: 2022-03-10Prima di tutto, non farlo a casa. Non scrivere le tue finestre di dialogo o una libreria per farlo. Ce ne sono già molti là fuori che sono stati testati, controllati, usati e riutilizzati e dovresti preferire questi ai tuoi. a11y-dialog è uno di questi, ma ce ne sono altri (elencati alla fine di questo articolo).
Consentitemi di prendere questo post come un'opportunità per ricordare a tutti voi di essere cauti quando usate i dialoghi . Si sta tentando di affrontare tutti i problemi di progettazione con loro, specialmente sui dispositivi mobili, ma spesso ci sono altri modi per superare i problemi di progettazione. Tendiamo a cadere rapidamente nell'uso dei dialoghi non perché siano necessariamente la scelta giusta, ma perché sono facili. Mettono da parte i problemi di proprietà dello schermo scambiandoli con il cambio di contesto, che non è sempre il giusto compromesso. Il punto è: valutare se una finestra di dialogo è il modello di progettazione giusto prima di utilizzarla.
In questo post, scriveremo una piccola libreria JavaScript per creare finestre di dialogo accessibili fin dall'inizio (essenzialmente ricreando una finestra di dialogo 11y). L'obiettivo è capire cosa c'è dentro. Non ci occuperemo troppo dello stile, ma solo della parte JavaScript. Useremo JavaScript moderno per semplicità (come classi e funzioni freccia), ma tieni presente che questo codice potrebbe non funzionare nei browser legacy.
- Definizione dell'API
- Istanziare il dialogo
- Mostrare e nascondere
- Chiusura con overlay
- Chiusura con fuga
- Messa a fuoco intrappolata
- Mantenere la concentrazione
- Ripristinare la concentrazione
- Dare un nome accessibile
- Gestione di eventi personalizzati
- Pulire
- Riunisci tutto
- Avvolgendo
Definizione dell'API
Innanzitutto, vogliamo definire come utilizzeremo il nostro script di dialogo. Per cominciare, lo terremo il più semplice possibile. Gli diamo l'elemento HTML radice per la nostra finestra di dialogo e l'istanza che otteniamo ha un metodo .show(.. .show(..)
e .hide(..)
.
class Dialog { constructor(element) {} show() {} hide() {} }
Istanziare il dialogo
Supponiamo di avere il seguente HTML:
<div>This will be a dialog.</div>
E istanziamo il nostro dialogo in questo modo:
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
Ci sono alcune cose che dobbiamo fare sotto il cofano quando si crea un'istanza:
- Nascondilo in modo che sia nascosto per impostazione predefinita (
hidden
). - Contrassegnalo come finestra di dialogo per le tecnologie assistive (
role="dialog"
). - Rendi inerte il resto della pagina quando è aperta (
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) }
Nota che avremmo potuto aggiungere questi 3 attributi nel nostro HTML iniziale per non doverli aggiungere con JavaScript, ma in questo modo è fuori dalla vista, fuori dalla mente. Il nostro script può garantire che le cose funzionino come dovrebbero, indipendentemente dal fatto che abbiamo pensato di aggiungere tutti i nostri attributi o meno.
Mostrare e nascondere
Abbiamo due metodi: uno per mostrare la finestra di dialogo e uno per nasconderla. Questi metodi non faranno molto (per ora) oltre a attivare l'attributo hidden
sull'elemento radice. Manterremo anche un valore booleano sull'istanza per poter valutare rapidamente se la finestra di dialogo è mostrata o meno. Questo tornerà utile in seguito.
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
Per evitare che la finestra di dialogo sia visibile prima che JavaScript si attivi e la nasconda aggiungendo l'attributo, potrebbe essere interessante aggiungere hidden
alla finestra di dialogo direttamente nell'HTML fin dall'inizio.
<div hidden>This will be a dialog.</div>
Chiusura Con Sovrapposizione
Fare clic al di fuori della finestra di dialogo dovrebbe chiuderla. Ci sono diversi modi per farlo. Un modo potrebbe essere ascoltare tutti gli eventi di clic sulla pagina e filtrare quelli che si verificano all'interno della finestra di dialogo, ma è relativamente complesso da fare.
Un altro approccio sarebbe ascoltare gli eventi di clic sull'overlay (a volte chiamato "sfondo"). L'overlay stesso può essere semplice come un <div>
con alcuni stili.
Quindi, quando si apre la finestra di dialogo, è necessario associare gli eventi di clic sull'overlay. Potremmo dargli un ID o una certa classe per poterlo interrogare, oppure potremmo dargli un attributo di dati. Tendo a preferire questi per ganci comportamentali. Modifichiamo il nostro HTML di conseguenza:
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
Ora possiamo interrogare gli elementi con l'attributo data-dialog-hide
all'interno della finestra di dialogo e assegnare loro un listener di clic che nasconde la finestra di dialogo.
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)) }
La cosa bella di avere qualcosa di abbastanza generico come questo è che possiamo usare la stessa cosa anche per il pulsante di chiusura della finestra di dialogo.
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Chiusura con fuga
Non solo la finestra di dialogo dovrebbe essere nascosta quando si fa clic al di fuori di essa, ma dovrebbe anche essere nascosta quando si preme Esc . Quando apriamo la finestra di dialogo, possiamo associare un listener da tastiera al documento e rimuoverlo quando lo chiudiamo. In questo modo, ascolta solo la pressione dei tasti mentre la finestra di dialogo è aperta anziché tutto il tempo.
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() }
Messa a fuoco intrappolata
Questa è la roba buona. Intrappolare l'attenzione all'interno del dialogo è in qualche modo l'essenza dell'intera faccenda e deve essere la parte più complicata (anche se probabilmente non così complicata come potresti pensare).
L'idea è piuttosto semplice: quando la finestra di dialogo è aperta, ascoltiamo le pressioni di tabulazione . Se si preme Tab sull'ultimo elemento attivabile della finestra di dialogo, spostiamo a livello di codice lo stato attivo sul primo. Se premi Maiusc + Tab sul primo elemento attivabile della finestra di dialogo, lo spostiamo sull'ultimo.
La funzione potrebbe assomigliare a questa:
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() } }
La prossima cosa che dobbiamo capire è come ottenere tutti gli elementi focalizzabili della finestra di dialogo ( getFocusableChildren
). Dobbiamo interrogare tutti gli elementi che possono essere teoricamente focalizzabili, e poi dobbiamo assicurarci che lo siano effettivamente.
La prima parte può essere eseguita con i selettori focalizzabili. È un minuscolo pacchetto che ho scritto che fornisce questa serie di selettori:
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^="-"])', ]
E questo è abbastanza per portarti lì al 99%. Possiamo usare questi selettori per trovare tutti gli elementi focalizzabili e quindi possiamo controllarli tutti per assicurarci che siano effettivamente visibili sullo schermo (e non nascosti o qualcosa del genere).
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) }
Ora possiamo aggiornare il nostro metodo handleKeyDown
:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
Mantenere la concentrazione
Una cosa che viene spesso trascurata quando si creano finestre di dialogo accessibili è assicurarsi che lo stato attivo rimanga all'interno della finestra di dialogo anche dopo che la pagina ha perso lo stato attivo. Pensala in questo modo: cosa succede se una volta aperta la finestra di dialogo? Mettiamo a fuoco la barra degli URL del browser, quindi ricominciamo a tabulare. Il nostro focus trap non funzionerà, poiché mantiene lo stato attivo all'interno della finestra di dialogo solo quando è all'interno della finestra di dialogo per cominciare.
Per risolvere questo problema, possiamo associare un ascoltatore del focus all'elemento <body>
quando viene mostrata la finestra di dialogo e spostare lo stato attivo sul primo elemento attivabile all'interno della finestra di dialogo.
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() }
L'elemento su cui concentrarsi quando si apre la finestra di dialogo non viene applicato e potrebbe dipendere dal tipo di contenuto visualizzato nella finestra di dialogo. In generale, ci sono un paio di opzioni:
- Metti a fuoco il primo elemento.
Questo è ciò che facciamo qui, poiché è reso più semplice dal fatto che abbiamo già una funzionegetFocusableChildren
. - Metti a fuoco il pulsante di chiusura.
Anche questa è una buona soluzione, soprattutto se il pulsante è assolutamente posizionato relativamente alla finestra di dialogo. Possiamo farlo comodamente posizionando il nostro pulsante di chiusura come primo elemento della nostra finestra di dialogo. Se il pulsante di chiusura si trova nel flusso del contenuto della finestra di dialogo, alla fine, potrebbe essere un problema se la finestra di dialogo ha molto contenuto (e quindi è scorrevole), poiché all'apertura scorrerebbe il contenuto fino alla fine. - Metti a fuoco la finestra di dialogo stessa .
Questo non è molto comune tra le librerie di finestre di dialogo, ma dovrebbe anche funzionare (sebbene richiederebbe l'aggiunta ditabindex="-1"
in modo che sia possibile poiché un elemento<div>
non è attivabile per impostazione predefinita).
Nota che controlliamo se c'è un elemento con l'attributo HTML autofocus
all'interno della finestra di dialogo, nel qual caso sposteremo lo stato attivo su di esso anziché sul primo elemento.
Ripristinare la messa a fuoco
Siamo riusciti a intrappolare correttamente lo stato attivo all'interno della finestra di dialogo, ma ci siamo dimenticati di spostare lo stato attivo all'interno della finestra di dialogo una volta aperta. Allo stesso modo, è necessario ripristinare lo stato attivo sull'elemento che lo aveva prima dell'apertura della finestra di dialogo.
Quando mostriamo la finestra di dialogo, possiamo iniziare mantenendo un riferimento all'elemento che ha il focus ( document.activeElement
). Il più delle volte, questo sarà il pulsante con cui è stato interagito per aprire la finestra di dialogo, ma in rari casi in cui una finestra di dialogo viene aperta a livello di codice, potrebbe essere qualcos'altro.
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
Quando nascondiamo la finestra di dialogo, possiamo riportare lo stato attivo su quell'elemento. Lo proteggiamo con una condizione per evitare un errore JavaScript se l'elemento in qualche modo non esiste più (o se fosse un SVG):
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
Dare un nome accessibile
È importante che la nostra finestra di dialogo abbia un nome accessibile, che è come verrà elencata nell'albero di accessibilità. Ci sono un paio di modi per affrontarlo, uno dei quali è definire un nome nell'attributo aria-label
, ma aria-label
ha dei problemi.
Un altro modo è avere un titolo all'interno della nostra finestra di dialogo (nascosto o meno) e associare la nostra finestra di dialogo ad essa con l'attributo aria-labelledby
. Potrebbe assomigliare a questo:
<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>
Immagino che potremmo fare in modo che il nostro script applichi questo attributo dinamicamente in base alla presenza del titolo e quant'altro, ma direi che questo è altrettanto facilmente risolvibile creando un HTML corretto, per cominciare. Non c'è bisogno di aggiungere JavaScript per questo.
Gestione di eventi personalizzati
E se volessimo reagire all'apertura della finestra di dialogo? O chiuso? Al momento non c'è modo di farlo, ma aggiungere un piccolo sistema di eventi non dovrebbe essere troppo difficile. Abbiamo bisogno di una funzione per registrare gli eventi (chiamiamola .on(..)
) e di una funzione per annullarne la registrazione ( .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) } }
Quindi, quando mostriamo e nascondiamo il metodo, chiameremo tutte le funzioni che sono state registrate per quel particolare evento.
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
Pulire
Potremmo voler fornire un metodo per ripulire una finestra di dialogo nel caso in cui abbiamo finito di usarlo. Sarebbe responsabile dell'annullamento della registrazione degli ascoltatori di eventi in modo che non durino più del dovuto.
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)) } }
Unendo tutto
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) }
Avvolgendo
Era piuttosto qualcosa, ma alla fine ci siamo arrivati! Ancora una volta, consiglierei di non implementare la propria libreria di finestre di dialogo poiché non è la più semplice e gli errori potrebbero essere altamente problematici per gli utenti di tecnologie assistive. Ma almeno ora sai come funziona sotto il cofano!
Se hai bisogno di utilizzare le finestre di dialogo nel tuo progetto, considera l'utilizzo di una delle seguenti soluzioni (ricordiamo che abbiamo anche il nostro elenco completo di componenti accessibili):
- Implementazioni JavaScript Vanilla: a11y-dialog dal tuo veramente o aria-modal-dialog di Scott O'Hara.
- Implementazioni React: react-a11y-dialog di nuovo con te, reach/dialog dal framework Reach o @react-aria/dialog da Adobe. Potrebbe interessarti questo confronto tra le 3 librerie.
- Implementazioni Vue: vue-a11y-dialog di Moritz Kroger, a11y-vue-dialog di Renato de Leao.
Ecco altre cose che potrebbero essere aggiunte ma non erano per semplicità:
- Supporto per finestre di dialogo di
alertdialog
tramite il ruolo di dialogo di avviso. Fare riferimento alla documentazione di a11y-dialog sui dialoghi di avviso. - Blocco della possibilità di scorrere mentre la finestra di dialogo è aperta. Fare riferimento alla documentazione della finestra di dialogo a11y sul blocco scorrimento.
- Supporto per l'elemento HTML
<dialog>
nativo perché è scadente e incoerente. Fare riferimento alla documentazione di a11y-dialog sull'elemento dialog e questo pezzo di Scott O'hara per ulteriori informazioni sul motivo per cui non vale la pena. - Supporto per finestre di dialogo nidificate perché discutibile. Fare riferimento alla documentazione di a11y-dialog sui dialoghi nidificati.
- Considerazione per la chiusura della finestra di dialogo sulla navigazione del browser. In alcuni casi, potrebbe avere senso chiudere la finestra di dialogo quando si preme il pulsante Indietro del browser.