從頭開始創建一個可訪問的對話框
已發表: 2022-03-10首先,不要在家裡這樣做。 不要編寫自己的對話框或庫來這樣做。 那裡已經有很多已經過測試、審核、使用和重用了,你應該更喜歡這些而不是你自己的。 a11y-dialog 就是其中之一,但還有更多(在本文末尾列出)。
藉這個帖子,提醒大家在使用對話框時要小心。 用它們來解決所有設計問題,尤其是在移動設備上,是很有誘惑力的,但通常還有其他方法可以克服設計問題。 我們傾向於很快陷入使用對話框,不是因為它們一定是正確的選擇,而是因為它們很容易。 他們通過交換上下文切換來解決屏幕空間問題,這並不總是正確的權衡。 關鍵是:在使用對話框之前考慮它是否是正確的設計模式。
在這篇文章中,我們將編寫一個小型 JavaScript 庫,用於從一開始就創建可訪問的對話框(本質上是重新創建 a11y-dialog)。 目標是了解其中的內容。 我們不會過多地處理樣式,只處理 JavaScript 部分。 為了簡單起見,我們將使用現代 JavaScript(例如類和箭頭函數),但請記住,此代碼可能不適用於舊版瀏覽器。
- 定義 API
- 實例化對話框
- 顯示和隱藏
- 用覆蓋關閉
- 以逃跑結束
- 捕捉焦點
- 保持專注
- 恢復焦點
- 提供可訪問的名稱
- 處理自定義事件
- 打掃乾淨
- 把它們放在一起
- 包起來
定義 API
首先,我們要定義我們將如何使用我們的對話腳本。 一開始我們會盡量保持簡單。 我們將對話框的根 HTML 元素賦予它,我們得到的實例有一個.show(..)
和一個 .hide( .hide(..)
方法。
class Dialog { constructor(element) {} show() {} hide() {} }
實例化對話框
假設我們有以下 HTML:
<div>This will be a dialog.</div>
我們像這樣實例化我們的對話框:
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
在實例化它時,我們需要在後台做一些事情:
- 隱藏它,使其默認隱藏(
hidden
)。 - 將其標記為輔助技術的對話框(
role="dialog"
)。 - 打開時使頁面的其餘部分保持惰性(
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) }
請注意,我們可以在初始 HTML 中添加這 3 個屬性,而不必使用 JavaScript 添加它們,但這樣一來它就看不見了,心不在焉。 無論我們是否考慮過添加所有屬性,我們的腳本都可以確保一切正常。
顯示和隱藏
我們有兩種方法:一種顯示對話框,另一種隱藏它。 除了切換根元素上的hidden
屬性之外,這些方法(目前)不會做太多事情。 我們還將在實例上維護一個布爾值,以便能夠快速評估對話框是否顯示。 這將在以後派上用場。
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
為了避免對話框在 JavaScript 啟動之前可見並通過添加屬性將其隱藏,從一開始就直接在 HTML 中將hidden
添加到對話框中可能會很有趣。
<div hidden>This will be a dialog.</div>
用覆蓋關閉
在對話框之外單擊應將其關閉。 有幾種方法可以做到這一點。 一種方法是監聽頁面上的所有點擊事件並過濾掉對話框中發生的事件,但這樣做相對複雜。
另一種方法是監聽疊加層上的點擊事件(有時稱為“背景”)。 疊加層本身可以像帶有一些樣式的<div>
一樣簡單。
所以在打開對話框時,我們需要在疊加層上綁定點擊事件。 我們可以給它一個 ID 或某個類以便能夠查詢它,或者我們可以給它一個數據屬性。 我傾向於將這些用於行為掛鉤。 讓我們相應地修改我們的 HTML:
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
現在,我們可以在對話框中使用data-dialog-hide
屬性查詢元素,並為它們提供一個隱藏對話框的點擊偵聽器。
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)) }
擁有像這樣通用的東西的好處是我們也可以對對話框的關閉按鈕使用相同的東西。
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
以 Escape 結束
不僅在單擊對話框外部時應該隱藏對話框,而且在按下Esc時也應該隱藏它。 打開對話框時,我們可以將鍵盤監聽器綁定到文檔,並在關閉時將其移除。 這樣,它只在對話框打開時而不是一直監聽按鍵。
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() }
誘捕焦點
現在這是好東西。 在對話中捕獲焦點是整個事情的本質,並且必須是最複雜的部分(儘管可能沒有您想像的那麼複雜)。
這個想法很簡單:當對話框打開時,我們監聽Tab按下。 如果在對話框的最後一個可聚焦元素上按Tab ,我們會以編程方式將焦點移至第一個。 如果在對話框的第一個可聚焦元素上按Shift + Tab ,我們會將其移動到最後一個。
該函數可能如下所示:
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() } }
接下來我們需要弄清楚的是如何獲取對話框的所有可聚焦元素( getFocusableChildren
)。 我們需要查詢所有理論上可以聚焦的元素,然後我們需要確保它們有效。
第一部分可以使用可聚焦選擇器完成。 這是我寫的一個很小的包,它提供了這個選擇器數組:
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^="-"])', ]
這足以讓你達到 99%。 我們可以使用這些選擇器來查找所有可聚焦的元素,然後我們可以檢查它們中的每一個以確保它在屏幕上實際上是可見的(而不是隱藏或其他什麼)。
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) }
我們現在可以更新我們的handleKeyDown
方法:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
保持專注
創建可訪問對話框時經常被忽略的一件事是確保即使在頁面失去焦點後焦點仍保留在對話框中。 可以這樣想:如果對話框打開會發生什麼? 我們聚焦瀏覽器的 URL 欄,然後再次開始 tabbing。 我們的焦點陷阱不起作用,因為它只在對話框內開始時才保留對話框內的焦點。
為了解決這個問題,我們可以在顯示對話框時將焦點偵聽器綁定到<body>
元素,並將焦點移動到對話框中的第一個可聚焦元素。
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() }
打開對話框時要關注哪個元素不是強制的,它可能取決於對話框顯示的內容類型。 一般來說,有兩種選擇:
- 聚焦第一個元素。
這就是我們在這裡所做的,因為我們已經有了一個getFocusableChildren
函數,所以它變得更容易了。 - 聚焦關閉按鈕。
這也是一個很好的解決方案,尤其是當按鈕相對於對話框絕對定位時。 我們可以通過將關閉按鈕作為對話框的第一個元素來方便地實現這一點。 如果關閉按鈕位於對話框內容的流中,最後,如果對話框有很多內容(因此是可滾動的),則可能會出現問題,因為它會在打開時將內容滾動到末尾。 - 聚焦對話本身。
這在對話框庫中不是很常見,但它也應該可以工作(儘管它需要向其中添加tabindex="-1"
以便可能,因為默認情況下<div>
元素不可聚焦)。
請注意,我們檢查對話框中是否存在具有autofocus
HTML 屬性的元素,在這種情況下,我們會將焦點移到它而不是第一項。
恢復焦點
我們已經成功地在對話框中捕獲了焦點,但是一旦它打開,我們就忘記了在對話框中移動焦點。 同樣,我們需要將焦點恢復到對話框打開之前擁有它的元素。
在顯示對話框時,我們可以首先保留對具有焦點的元素( document.activeElement
)的引用。 大多數情況下,這將是與打開對話框交互的按鈕,但在以編程方式打開對話框的極少數情況下,它可能是其他東西。
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
隱藏對話框時,我們可以將焦點移回該元素。 如果元素以某種方式不再存在(或者如果它是 SVG),我們會用一個條件來保護它以避免 JavaScript 錯誤:
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
給出一個易於訪問的名稱
重要的是我們的對話框有一個可訪問的名稱,這就是它在可訪問性樹中的列出方式。 有幾種方法可以解決它,其中一種是在aria-label
屬性中定義一個名稱,但是aria-label
有問題。
另一種方法是在我們的對話框中有一個標題(無論是否隱藏),並將我們的對話框與aria-labelledby
屬性相關聯。 它可能看起來像這樣:
<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>
我想我們可以讓我們的腳本根據標題和諸如此類的存在動態地應用這個屬性,但我想說這很容易通過編寫適當的 HTML 來解決,首先。 無需為此添加 JavaScript。
處理自定義事件
如果我們想對打開的對話框做出反應怎麼辦? 還是關門了? 目前沒有辦法做到,但是添加一個小事件系統應該不會太難。 我們需要一個註冊事件的函數(我們稱之為.on(..)
),以及一個取消註冊事件的函數( .off(.. .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) } }
然後在顯示和隱藏該方法時,我們將調用已為該特定事件註冊的所有函數。
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
打掃乾淨
我們可能希望提供一種方法來清理對話框,以防我們使用完它。 它將負責註銷事件偵聽器,因此它們不會持續超過應有的時間。
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)) } }
把它放在一起
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) }
包起來
那是相當的東西,但我們最終到達了那裡! 再一次,我建議不要推出你自己的對話框庫,因為它不是最直接的,並且錯誤可能會給輔助技術用戶帶來很大的問題。 但至少現在你知道它是如何工作的了!
如果您需要在項目中使用對話框,請考慮使用以下解決方案之一(請注意,我們也有可訪問組件的完整列表):
- Vanilla JavaScript 實現:你的 a11y-dialog 或 Scott O'Hara 的 aria-modal-dialog。
- React 實現:react-a11y-dialog 來自你的真正,來自 Reach 框架的reach/dialog,或來自 Adobe 的@react-aria/dialog。 您可能會對這 3 個庫的比較感興趣。
- Vue 實現:Moritz Kroger 的 vue-a11y-dialog,Renato de Leao 的 a11y-vue-dialog。
這裡有更多可以添加的東西,但不是為了簡單起見:
- 通過
alertdialog
角色支持警報對話框。 請參閱有關警報對話框的 a11y-dialog 文檔。 - 鎖定對話框打開時滾動的能力。 請參閱有關滾動鎖定的 a11y-dialog 文檔。
- 支持原生 HTML
<dialog>
元素,因為它低於標準且不一致。 請參閱有關對話框元素的 a11y-dialog 文檔和 Scott O'hara 的這篇文章,以獲取有關為什麼不值得麻煩的更多信息。 - 支持嵌套對話框,因為它是有問題的。 請參閱關於嵌套對話框的 a11y-dialog 文檔。
- 關閉瀏覽器導航對話框的考慮。 在某些情況下,按下瀏覽器的後退按鈕時關閉對話框可能是有意義的。