Tworzenie dostępnego okna dialogowego od podstaw

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Okna dialogowe są wszędzie w nowoczesnym stylu interfejsu (na dobre lub na złe), a jednak wiele z nich nie jest dostępnych dla technologii wspomagających. W tym artykule dowiemy się, jak stworzyć krótki skrypt do tworzenia dostępnych okien dialogowych.

Przede wszystkim nie rób tego w domu. Nie pisz własnych okien dialogowych ani biblioteki, aby to zrobić. Jest ich już wiele, które zostały przetestowane, poddane audytowi, użyte i ponownie użyte, i powinieneś preferować te od własnych. a11y-dialog jest jednym z nich, ale jest ich więcej (wymienione na końcu tego artykułu).

Pozwolę sobie potraktować ten post jako okazję do przypomnienia wszystkim o ostrożności podczas korzystania z okien dialogowych . Rozwiązanie za ich pomocą wszystkich problemów projektowych, zwłaszcza na urządzeniach mobilnych, jest trudne, ale często istnieją inne sposoby na pokonanie problemów projektowych. Mamy tendencję do szybkiego używania okien dialogowych nie dlatego, że są one koniecznie właściwym wyborem, ale dlatego, że są łatwe. Odsuwają na bok problemy z właściwościami ekranu, wymieniając je na przełączanie kontekstu, co nie zawsze jest właściwym kompromisem. Chodzi o to: zastanów się, czy okno dialogowe jest właściwym wzorcem projektowym przed jego użyciem.

W tym poście napiszemy małą bibliotekę JavaScript do tworzenia dostępnych okien dialogowych od samego początku (zasadniczo odtwarzając a11y-dialog). Celem jest zrozumienie, co się w to dzieje. Nie będziemy zajmować się zbytnio stylizacją, tylko część JavaScript. Dla uproszczenia użyjemy nowoczesnego JavaScript (takiego jak klasy i funkcje strzałek), ale pamiętaj, że ten kod może nie działać w starszych przeglądarkach.

  1. Definiowanie API
  2. Uruchamianie okna dialogowego
  3. Pokazywanie i ukrywanie
  4. Zamknięcie z nakładką
  5. Zamknięcie z ucieczką
  6. Koncentracja na pułapce
  7. Utrzymywanie koncentracji
  8. Przywracanie ostrości
  9. Nadanie przystępnej nazwy
  10. Obsługa zdarzeń niestandardowych
  11. Sprzątanie
  12. Połącz to wszystko razem
  13. Zawijanie

Definiowanie API

Najpierw chcemy zdefiniować, w jaki sposób będziemy używać naszego skryptu dialogowego. Na początku zamierzamy zachować to tak prosto, jak to tylko możliwe. Dajemy mu główny element HTML dla naszego okna dialogowego, a otrzymana instancja ma metodę .show(.. .show(..) i .hide(..) .

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

Tworzenie instancji dialogu

Załóżmy, że mamy następujący kod HTML:

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

I tworzymy nasze okno dialogowe w ten sposób:

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

Jest kilka rzeczy, które musimy zrobić pod maską podczas tworzenia jego instancji:

  • Ukryj to, aby było domyślnie ukryte ( hidden ).
  • Oznacz to jako okno dialogowe dla technologii wspomagających ( role="dialog" ).
  • Ustaw resztę strony w stanie obojętnym po otwarciu ( 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) }

Zauważ, że mogliśmy dodać te 3 atrybuty do naszego początkowego kodu HTML, aby nie trzeba było ich dodawać za pomocą JavaScript, ale w ten sposób jest to poza zasięgiem wzroku, z umysłu. Nasz skrypt może upewnić się, że wszystko będzie działać tak, jak powinno, niezależnie od tego, czy pomyśleliśmy o dodaniu wszystkich naszych atrybutów, czy nie.

Pokazywanie i ukrywanie

Mamy dwie metody: jedną, aby wyświetlić okno dialogowe, a drugą, aby je ukryć. Te metody nie zrobią wiele (na razie) poza przełączaniem hidden atrybutu w elemencie głównym. Zamierzamy również zachować wartość logiczną dla instancji, aby szybko móc ocenić, czy okno dialogowe jest wyświetlane, czy nie. Przyda się to później.

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

Aby uniknąć wyświetlania okna dialogowego przed uruchomieniem JavaScriptu i ukrycia go przez dodanie atrybutu, może być interesujące dodanie hidden okna dialogowego bezpośrednio w kodzie HTML od samego początku.

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

Zamykanie za pomocą nakładki

Kliknięcie poza oknem dialogowym powinno je zamknąć. Można to zrobić na kilka sposobów. Jednym ze sposobów może być odsłuchanie wszystkich kliknięć na stronie i odfiltrowanie tych, które mają miejsce w oknie dialogowym, ale jest to dość skomplikowane do zrobienia.

Innym podejściem byłoby słuchanie zdarzeń kliknięć na nakładce (czasami nazywanych „tłem”). Sama nakładka może być tak prosta jak <div> z niektórymi stylami.

Dlatego otwierając okno dialogowe, musimy powiązać zdarzenia kliknięcia na nakładce. Moglibyśmy nadać mu identyfikator lub określoną klasę, aby móc go wysłać, albo moglibyśmy nadać mu atrybut danych. Preferuję je jako haki behawioralne. Zmodyfikujmy odpowiednio nasz kod HTML:

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

Teraz możemy wysłać zapytanie do elementów za pomocą atrybutu data-dialog-hide w oknie dialogowym i dać im odbiornik kliknięć, który ukrywa okno dialogowe.

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

Zaletą posiadania czegoś tak ogólnego jest to, że możemy użyć tego samego dla przycisku zamykania okna dialogowego.

 <div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Więcej po skoku! Kontynuuj czytanie poniżej ↓

Zamknięcie z ucieczką

Okno dialogowe powinno być ukryte nie tylko po kliknięciu poza nim, ale także po naciśnięciu klawisza Esc . Otwierając okno dialogowe, możemy powiązać słuchacz klawiatury z dokumentem i usunąć go podczas zamykania. W ten sposób nasłuchuje tylko naciśnięć klawiszy, gdy okno dialogowe jest otwarte, a nie przez cały czas.

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

Skupienie pułapki

To jest dobra rzecz. Uwięzienie fokusu w oknie dialogowym jest swego rodzaju istotą całej sprawy i musi być najbardziej skomplikowaną częścią (choć prawdopodobnie nie tak skomplikowaną, jak mogłoby się wydawać).

Pomysł jest dość prosty: gdy okno dialogowe jest otwarte, nasłuchujemy naciśnięć Tab . Jeśli naciśniesz klawisz Tab na ostatnim elemencie okna dialogowego, na który można ustawić fokus, programowo przeniesiemy fokus na pierwszy. Jeśli naciśniesz Shift + Tab na pierwszym elemencie okna dialogowego, na który można się skupić, przeniesiemy go do ostatniego.

Funkcja może wyglądać tak:

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

Następną rzeczą, którą musimy dowiedzieć się, jest to, jak uzyskać wszystkie elementy okna dialogowego, na które można się skupić ( getFocusableChildren ). Musimy zapytać o wszystkie elementy, na których teoretycznie można się skoncentrować, a następnie upewnić się, że są one efektywne.

Pierwszą część można wykonać za pomocą selektorów z możliwością ustawiania ostrości. Jest to malusieńki, mały pakiet, który napisałem, który zawiera tę tablicę selektorów:

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

A to wystarczy, aby dotrzeć tam w 99%. Możemy użyć tych selektorów, aby znaleźć wszystkie elementy, na które można ustawić ostrość, a następnie sprawdzić każdy z nich, aby upewnić się, że jest rzeczywiście widoczny na ekranie (a nie ukryty lub coś takiego).

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

Możemy teraz zaktualizować naszą metodę handleKeyDown :

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

Utrzymywanie koncentracji

Jedną z rzeczy, która często jest pomijana podczas tworzenia dostępnych okien dialogowych, jest upewnienie się, że fokus pozostaje w oknie dialogowym nawet po utracie fokusu na stronie. Pomyśl o tym w ten sposób: co się stanie, jeśli okno dialogowe zostanie otwarte? Koncentrujemy się na pasku adresu przeglądarki, a następnie ponownie zaczynamy tabulatory. Nasza pułapka fokusowa nie zadziała, ponieważ na początku zachowuje fokus w oknie dialogowym tylko wtedy, gdy jest ono w nim.

Aby rozwiązać ten problem, możemy powiązać fokus słuchacza z elementem <body> , gdy wyświetlane jest okno dialogowe, i przenieść fokus na pierwszy element, na który można się skupić w oknie dialogowym.

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

Element, na którym należy się skupić podczas otwierania okna dialogowego, nie jest wymuszany i może zależeć od rodzaju wyświetlanej zawartości. Ogólnie rzecz biorąc, istnieje kilka opcji:

  • Skup się na pierwszym elemencie.
    To właśnie robimy tutaj, ponieważ ułatwia to fakt, że mamy już funkcję getFocusableChildren .
  • Ustaw ostrość przycisku zamykania.
    Jest to również dobre rozwiązanie, zwłaszcza jeśli przycisk jest ustawiony bezwzględnie względem okna dialogowego. Możemy to wygodnie zrobić, umieszczając nasz przycisk zamykania jako pierwszy element naszego okna dialogowego. Jeśli przycisk zamykania znajduje się w przepływie zawartości okna dialogowego, na samym końcu może być problemem, jeśli okno dialogowe ma dużo treści (a zatem jest przewijalne), ponieważ przy otwarciu przewijałoby zawartość do końca.
  • Skup się na samym oknie dialogowym .
    Nie jest to zbyt częste wśród bibliotek dialogowych, ale powinno również działać (chociaż wymagałoby to dodania do niego tabindex="-1" , więc jest to możliwe, ponieważ element <div> nie jest domyślnie aktywny).

Zwróć uwagę, że sprawdzamy, czy w oknie dialogowym znajduje się element z atrybutem HTML autofocus , w którym to przypadku przeniesiemy fokus na niego zamiast na pierwszy element.

Przywracanie skupienia

Udało nam się z powodzeniem uwięzić fokus w oknie dialogowym, ale zapomnieliśmy przenieść fokus wewnątrz okna po jego otwarciu. Podobnie musimy przywrócić fokus z powrotem do elementu, który miał go przed otwarciem okna dialogowego.

Wyświetlając okno dialogowe, możemy zacząć od zachowania odniesienia do elementu, który ma fokus ( document.activeElement ). W większości przypadków będzie to przycisk, z którym weszło w interakcję, aby otworzyć okno dialogowe, ale w rzadkich przypadkach, gdy okno dialogowe jest otwierane programowo, może to być coś innego.

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

Ukrywając okno dialogowe, możemy przenieść fokus z powrotem na ten element. Chronimy go warunkiem, aby uniknąć błędu JavaScript, jeśli element jakoś już nie istnieje (lub jeśli był to SVG):

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

Nadawanie przystępnej nazwy

Ważne jest, aby nasze okno dialogowe miało dostępną nazwę, dzięki czemu będzie wyświetlane w drzewie dostępności. Istnieje kilka sposobów rozwiązania tego problemu, jednym z nich jest zdefiniowanie nazwy w atrybucie aria-label , ale aria-label ma pewne problemy.

Innym sposobem jest umieszczenie tytułu w naszym oknie dialogowym (ukrytego lub nie) i powiązanie z nim naszego okna dialogowego za pomocą atrybutu aria-labelledby . Może to wyglądać tak:

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

Myślę, że moglibyśmy sprawić, by nasz skrypt zastosował ten atrybut dynamicznie w oparciu o obecność tytułu i tak dalej, ale powiedziałbym, że jest to równie łatwe do rozwiązania, na początku tworząc odpowiedni kod HTML. Nie ma potrzeby dodawania do tego JavaScript.

Obsługa zdarzeń niestandardowych

A jeśli chcemy zareagować na otwierające się okno dialogowe? Czy zamknięte? Obecnie nie ma na to możliwości, ale dodanie systemu małych wydarzeń nie powinno być zbyt trudne. Potrzebujemy funkcji do rejestracji zdarzeń (nazwijmy ją .on(..) ) oraz funkcji do ich wyrejestrowania ( .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) } }

Następnie podczas pokazywania i ukrywania metody wywołamy wszystkie funkcje, które zostały zarejestrowane dla tego konkretnego zdarzenia.

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

Sprzątanie

Możemy chcieć zapewnić metodę czyszczenia okna dialogowego na wypadek, gdybyśmy już z niego korzystali. Byłby odpowiedzialny za wyrejestrowanie słuchaczy zdarzeń, aby nie wytrzymywały dłużej niż powinny.

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

Łącząc to wszystko razem

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

Zawijanie

To było coś, ale w końcu tam dotarliśmy! Jeszcze raz odradzałbym rozwijanie własnej biblioteki okien dialogowych, ponieważ nie jest to najprostsza, a błędy mogą być bardzo problematyczne dla użytkowników technologii wspomagających. Ale przynajmniej teraz wiesz, jak to działa pod maską!

Jeśli potrzebujesz użyć okien dialogowych w swoim projekcie, rozważ użycie jednego z poniższych rozwiązań (przypominamy, że mamy również naszą obszerną listę dostępnych komponentów):

  • Waniliowe implementacje JavaScript: a11y-dialog od Ciebie naprawdę lub aria-modal-dialog od Scotta O'Hary.
  • Implementacje Reacta: React-a11y-dialog naprawdę ponownie, reach/dialog z frameworka Reach lub @react-aria/dialog od Adobe. Może zainteresuje Cię to porównanie 3 bibliotek.
  • Implementacje Vue: vue-a11y-dialog autorstwa Moritza Krogera, a11y-vue-dialog autorstwa Renato de Leao.

Oto więcej rzeczy, które można by dodać, ale nie dla uproszczenia:

  • Obsługa okien dialogowych alertów za pośrednictwem roli alertdialog . Zapoznaj się z dokumentacją a11y-dialog na temat okien dialogowych alertów.
  • Blokowanie możliwości przewijania, gdy okno dialogowe jest otwarte. Zapoznaj się z dokumentacją okna dialogowego a11y na temat scroll lock.
  • Obsługa natywnego elementu HTML <dialog> , ponieważ jest on podrzędny i niespójny. Zapoznaj się z dokumentacją a11y-dialog na temat elementu dialog i tym artykułem autorstwa Scotta O'hary, aby uzyskać więcej informacji o tym, dlaczego nie jest to warte zachodu.
  • Wsparcie dla zagnieżdżonych okien dialogowych, ponieważ jest to wątpliwe. Zapoznaj się z dokumentacją a11y-dialog na temat zagnieżdżonych okien dialogowych.
  • Rozważenie zamknięcia okna dialogowego podczas nawigacji w przeglądarce. W niektórych przypadkach sensowne może być zamknięcie okna dialogowego po naciśnięciu przycisku Wstecz w przeglądarce.