Tạo hộp thoại có thể truy cập từ đầu

theanh

Administrator
Nhân viên
Trước hết, đừng làm điều này ở nhà. Đừng tự viết hộp thoại hoặc thư viện để làm như vậy. Đã có rất nhiều hộp thoại đã được thử nghiệm, kiểm tra, sử dụng và tái sử dụng và bạn nên ưu tiên những hộp thoại này hơn hộp thoại của riêng mình. a11y-dialog là một trong số đó, nhưng vẫn còn nhiều hộp thoại khác (được liệt kê ở cuối bài viết này).

Tôi xin phép đăng bài này như một cơ hội để nhắc nhở tất cả các bạn hãy thận trọng khi sử dụng hộp thoại. Có vẻ như chúng ta đang cố gắng giải quyết mọi vấn đề về thiết kế bằng hộp thoại, đặc biệt là trên thiết bị di động, nhưng thường có những cách khác để khắc phục các vấn đề về thiết kế. Chúng ta có xu hướng nhanh chóng sử dụng hộp thoại không phải vì chúng nhất thiết là lựa chọn đúng đắn mà vì chúng dễ sử dụng. Chúng loại bỏ các vấn đề về bất động sản màn hình bằng cách đổi chúng lấy chuyển đổi ngữ cảnh, điều này không phải lúc nào cũng là sự đánh đổi đúng đắn. Vấn đề là: hãy cân nhắc xem hộp thoại có phải là mẫu thiết kế phù hợp hay không trước khi sử dụng.

Trong bài đăng này, chúng ta sẽ viết một thư viện JavaScript nhỏ để tạo hộp thoại có thể truy cập ngay từ đầu (về cơ bản là tạo lại a11y-dialog). Mục tiêu là hiểu những gì nằm trong đó. Chúng ta sẽ không giải quyết quá nhiều về kiểu dáng, chỉ giải quyết phần JavaScript. Chúng tôi sẽ sử dụng JavaScript hiện đại vì mục đích đơn giản (chẳng hạn như các lớp và hàm mũi tên), nhưng hãy nhớ rằng mã này có thể không hoạt động trong các trình duyệt cũ.
  1. Định nghĩa API
  2. Khởi tạo hộp thoại
  3. Hiển thị và ẩn
  4. Đóng bằng lớp phủ
  5. Đóng bằng escape
  6. Giữ tiêu điểm
  7. Duy trì tiêu điểm
  8. Khôi phục tiêu điểm
  9. Cung cấp một name
  10. Xử lý các sự kiện tùy chỉnh
  11. Dọn dẹp
  12. Kết hợp tất cả lại
  13. Kết thúc

Định nghĩa API​

Đầu tiên, chúng ta muốn định nghĩa cách sử dụng tập lệnh hộp thoại của mình. Chúng ta sẽ giữ cho nó đơn giản nhất có thể khi bắt đầu. Chúng ta cung cấp cho nó phần tử HTML gốc cho hộp thoại của chúng ta và thể hiện chúng ta nhận được có phương thức .show(..).hide(..).
Mã:
class Dialog { constructor(element) {} show() {} hide() {}}

Khởi tạo hộp thoại​

Giả sử chúng ta có HTML sau:
Mã:
Đây sẽ là một hộp thoại.
Và chúng ta khởi tạo hộp thoại của mình như thế này:
Mã:
const element = document.querySelector('#my-dialog')const dialog = new Dialog(element)
Có một vài điều chúng ta cần thực hiện khi khởi tạo nó:
  • Ẩn nó để nó được ẩn theo mặc định (hidden).
  • Đánh dấu nó là hộp thoại cho các công nghệ hỗ trợ (role="dialog").
  • Làm cho phần còn lại của trang trở nên trơ khi mở (aria-modal="true").
Mã:
constructor (element) { // Lưu trữ tham chiếu đến phần tử HTML trên phiên bản để có thể sử dụng // trên các phương thức. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true)}
Lưu ý rằng chúng ta có thể thêm 3 thuộc tính này vào HTML ban đầu của mình để không phải thêm chúng bằng JavaScript, nhưng theo cách này, chúng sẽ không bị nhìn thấy, không bị nhớ đến. Script của chúng ta có thể đảm bảo mọi thứ sẽ hoạt động như mong đợi, bất kể chúng ta đã nghĩ đến việc thêm tất cả các thuộc tính của mình hay chưa.

Hiển thị và ẩn​

Chúng ta có hai phương pháp: một để hiển thị hộp thoại và một để ẩn hộp thoại. Các phương pháp này sẽ không làm được nhiều (hiện tại) ngoài việc bật tắt thuộc tính hidden trên phần tử gốc. Chúng ta cũng sẽ duy trì một boolean trên phiên bản để có thể nhanh chóng đánh giá xem hộp thoại có được hiển thị hay không. Điều này sẽ hữu ích sau này.
Mã:
show() { this.isShown = true this.element.removeAttribute('hidden')}hide() { this.isShown = false this.element.setAttribute('hidden', true)}
Để tránh hộp thoại hiển thị trước khi JavaScript khởi động và ẩn nó bằng cách thêm thuộc tính, có thể thêm hidden vào hộp thoại trực tiếp trong HTML ngay từ đầu.
Mã:
Đây sẽ là một hộp thoại.

Đóng bằng lớp phủ​

Nhấp chuột ra ngoài hộp thoại sẽ đóng hộp thoại lại. Có một số cách để thực hiện. Một cách có thể là lắng nghe tất cả các sự kiện nhấp chuột trên trang và lọc ra những sự kiện xảy ra trong hộp thoại, nhưng cách này khá phức tạp.

Một cách tiếp cận khác là lắng nghe các sự kiện nhấp chuột trên lớp phủ (đôi khi được gọi là "phông nền"). Bản thân lớp phủ có thể đơn giản như với một số kiểu.

Vì vậy, khi mở hộp thoại, chúng ta cần liên kết các sự kiện nhấp chuột trên lớp phủ. Chúng ta có thể cung cấp cho nó một ID hoặc một lớp nhất định để có thể truy vấn nó hoặc chúng ta có thể cung cấp cho nó một thuộc tính dữ liệu. Tôi có xu hướng ưu tiên những điều này cho các móc hành vi. Hãy sửa đổi HTML của chúng ta cho phù hợp:
Mã:
  Đây sẽ là một hộp thoại.
Bây giờ, chúng ta có thể truy vấn các phần tử có thuộc tính data-dialog-hide trong hộp thoại và cung cấp cho chúng một trình lắng nghe nhấp chuột để ẩn hộp thoại.
Mã:
constructor (element) { // … phần còn lại của mã // Liên kết các phương thức của chúng ta để chúng có thể được sử dụng trong trình lắng nghe sự kiện mà không mất // tham chiếu đến phiên bản hộp thoại 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))}
Điều tuyệt vời khi có một thứ khá chung chung như thế này là chúng ta cũng có thể sử dụng cùng một thứ cho nút đóng của hộp thoại.
Mã:
   Đây sẽ là một hộp thoại. Đóng

Đóng bằng Escape​

Hộp thoại không chỉ nên ẩn khi nhấp ra ngoài mà còn nên ẩn khi nhấn Esc. Khi mở hộp thoại, chúng ta có thể liên kết trình nghe bàn phím với tài liệu và xóa trình nghe đó khi đóng hộp thoại. Theo cách này, nó chỉ lắng nghe các lần nhấn phím khi hộp thoại mở thay vì lắng nghe mọi lúc.
Mã:
show() { // … phần còn lại của mã // Lưu ý: `_handleKeyDown` là phương thức liên kết, giống như chúng ta đã làm với `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown)}hide() { // … phần còn lại của mã // Lưu ý: `_handleKeyDown` là phương thức liên kết, giống như chúng ta đã làm với `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown)}handleKeyDown(event) { if (event.key === 'Escape') this.hide()}

Trapping Focus​

Giờ thì đến phần hay rồi. Việc giữ trọng tâm trong hộp thoại là cốt lõi của toàn bộ mọi thứ và phải là phần phức tạp nhất (mặc dù có lẽ không phức tạp như bạn nghĩ).

Ý tưởng khá đơn giản: khi hộp thoại mở, chúng ta lắng nghe các lần nhấn Tab. Nếu nhấn Tab vào phần tử có thể lấy tiêu điểm cuối cùng của hộp thoại, chúng ta sẽ lập trình di chuyển trọng tâm đến phần tử đầu tiên. Nếu nhấn Shift + Tab trên phần tử có thể lấy tiêu điểm đầu tiên của hộp thoại, chúng ta sẽ di chuyển nó đến phần tử cuối cùng.

Hàm có thể trông như thế này:
Mã:
function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) constfocusedItemIndex = 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() }}
Điều tiếp theo chúng ta cần tìm ra là làm thế nào để lấy được tất cả các phần tử có thể lấy nét của hộp thoại (getFocusableChildren). Chúng ta cần truy vấn tất cả các phần tử về mặt lý thuyết có thể lấy nét được, sau đó chúng ta cần đảm bảo chúng thực sự có thể lấy nét được.

Phần đầu tiên có thể được thực hiện bằng focusable-selectors. Đây là một gói nhỏ xíu mà tôi đã viết cung cấp mảng các bộ chọn này:
Mã:
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^="-"])',]
Và thế là đủ để bạn đạt được 99% mục tiêu. Chúng ta có thể sử dụng các bộ chọn này để tìm tất cả các phần tử có thể lấy nét, sau đó chúng ta có thể kiểm tra từng phần tử để đảm bảo rằng chúng thực sự hiển thị trên màn hình (và không bị ẩn hoặc gì đó).
Mã:
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)}
Bây giờ chúng ta có thể cập nhật phương thức handleKeyDown của mình:
Mã:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event)}

Duy trì tiêu điểm​

Một điều thường bị bỏ qua khi tạo hộp thoại có thể truy cập là đảm bảo tiêu điểm vẫn nằm trong hộp thoại ngay cả khi sau khi trang đã mất tiêu điểm. Hãy nghĩ theo cách này: điều gì sẽ xảy ra nếu hộp thoại mở ra? Chúng ta tập trung vào thanh URL của trình duyệt, sau đó bắt đầu nhấn tab lần nữa. Bẫy tiêu điểm của chúng ta sẽ không hoạt động, vì nó chỉ giữ nguyên tiêu điểm trong hộp thoại khi tiêu điểm nằm trong hộp thoại ngay từ đầu.

Để khắc phục sự cố đó, chúng ta có thể liên kết trình lắng nghe tiêu điểm với phần tử khi hộp thoại được hiển thị và di chuyển tiêu điểm đến phần tử có thể lấy tiêu điểm đầu tiên trong hộp thoại.
Mã:
show () { // … phần còn lại của mã // Lưu ý: `_maintainFocus` là phương thức liên kết, giống như chúng ta đã làm với `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true)}hide () { // … phần còn lại của mã // Lưu ý: `_maintainFocus` là phương thức liên kết, giống như chúng ta đã làm với `_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()}
Phần tử nào cần tập trung khi mở hộp thoại không được áp dụng và có thể phụ thuộc vào loại nội dung mà hộp thoại hiển thị. Nhìn chung, có một số tùy chọn:
  • Tập trung vào phần tử đầu tiên.
    Đây là những gì chúng ta làm ở đây, vì nó dễ dàng hơn do chúng ta đã có hàm getFocusableChildren.
  • Tập trung vào nút đóng.
    Đây cũng là một giải pháp tốt, đặc biệt là nếu nút được định vị tuyệt đối so với hộp thoại. Chúng ta có thể thực hiện điều này một cách thuận tiện bằng cách đặt nút đóng làm phần tử đầu tiên của hộp thoại. Nếu nút đóng nằm trong luồng nội dung hộp thoại, thì ở phần cuối, có thể sẽ có vấn đề nếu hộp thoại có nhiều nội dung (và do đó có thể cuộn được), vì nó sẽ cuộn nội dung đến cuối khi mở.
  • Tập trung vào chính hộp thoại.
    Điều này không phổ biến lắm trong các thư viện hộp thoại, nhưng nó cũng có thể hoạt động (mặc dù nó sẽ yêu cầu thêm tabindex="-1" vào đó để có thể thực hiện được vì phần tử không thể lấy tiêu điểm theo mặc định).
Lưu ý rằng chúng tôi kiểm tra xem có phần tử nào có thuộc tính HTML autofocus trong hộp thoại hay không, trong trường hợp đó, chúng tôi sẽ di chuyển tiêu điểm đến phần tử đó thay vì mục đầu tiên.

Khôi phục tiêu điểm​

Chúng tôi đã thành công trong việc giữ tiêu điểm trong hộp thoại, nhưng chúng tôi quên di chuyển tiêu điểm vào bên trong hộp thoại sau khi hộp thoại mở ra. Tương tự như vậy, chúng tôi cần khôi phục tiêu điểm trở lại phần tử đã có tiêu điểm trước khi hộp thoại mở ra.

Khi hiển thị hộp thoại, chúng tôi có thể bắt đầu bằng cách giữ tham chiếu đến phần tử có tiêu điểm (document.activeElement). Hầu hết thời gian, đây sẽ là nút đã được tương tác để mở hộp thoại, nhưng trong những trường hợp hiếm hoi khi hộp thoại được mở theo chương trình, thì có thể là nút khác.
Mã:
show() { this.previouslyFocused = document.activeElement // … phần còn lại của mã this.moveFocusIn()}
Khi ẩn hộp thoại, chúng tôi có thể di chuyển tiêu điểm trở lại phần tử đó. Chúng tôi bảo vệ nó bằng một điều kiện để tránh lỗi JavaScript nếu phần tử không còn tồn tại nữa (hoặc nếu đó là SVG):
Mã:
hide() { // … phần còn lại của mã if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() }}

Đặt tên có thể truy cập được​

Điều quan trọng là hộp thoại của chúng ta phải có tên có thể truy cập được, đó là cách nó sẽ được liệt kê trong cây có thể truy cập được. Có một vài cách để giải quyết vấn đề này, một trong số đó là định nghĩa tên trong thuộc tính aria-label, nhưng aria-label có vấn đề.

Một cách khác là có tiêu đề trong hộp thoại của chúng ta (có ẩn hay không) và liên kết hộp thoại của chúng ta với tiêu đề đó bằng thuộc tính aria-labelledby. Nó có thể trông như thế này:
Mã:
   [HEADING=1]Tiêu đề hộp thoại của tôi[/HEADING] Đây sẽ là một hộp thoại. Đóng
Tôi đoán chúng ta có thể khiến tập lệnh của mình áp dụng thuộc tính này một cách động dựa trên sự hiện diện của tiêu đề và những thứ tương tự, nhưng tôi cho rằng điều này cũng dễ giải quyết bằng cách tạo HTML phù hợp ngay từ đầu. Không cần thêm JavaScript cho việc đó.

Xử lý Sự kiện Tùy chỉnh​

Còn nếu chúng ta muốn phản ứng với hộp thoại đang mở thì sao? Hay đang đóng? Hiện tại không có cách nào để thực hiện, nhưng việc thêm một hệ thống sự kiện nhỏ sẽ không quá khó. Chúng ta cần một hàm để đăng ký sự kiện (gọi là .on(..)) và một hàm để hủy đăng ký chúng (.off(..)).
Mã:
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) }}
Sau đó, khi hiển thị và ẩn phương thức, chúng ta sẽ gọi tất cả các hàm đã được đăng ký cho sự kiện cụ thể đó.
Mã:
class Dialog { show() { // … phần còn lại của mã this.events.show.forEach(event == event()) } hide() { // … phần còn lại của mã this.events.hide.forEach(event == event()) }}

Dọn dẹp​

Chúng ta có thể muốn cung cấp một phương thức để dọn dẹp hộp thoại trong trường hợp chúng ta đã sử dụng xong. Nó sẽ chịu trách nhiệm hủy đăng ký trình lắng nghe sự kiện để chúng không tồn tại lâu hơn mức cần thiết.
Mã:
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)) }}

Kết hợp tất cả lại với nhau​

Mã:
nhập focusableSelectors từ '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('ẩn') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(sự kiện => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('ẩn', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(sự kiện => sự kiện()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(sự kiện => this.off('show', sự kiện)) this.events.hide.forEach(sự kiện => this.off('hide', sự kiện)) } on(kiểu, fn) { this.events[kiểu].push(fn) } off(kiểu, fn) { const index = this.events[kiểu].indexOf(fn) if (chỉ mục > -1) this.events[kiểu].splice(chỉ mục, 1) } handleKeyDown(sự kiện) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(phần tử này, sự kiện) } moveFocusIn() { const target = phần tử này.querySelector('[tự động lấy nét]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() }}hàm trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) constfocusedItemIndex = 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() }}hàm isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length}function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible)}

Kết thúc​

Thật là khó khăn, nhưng cuối cùng chúng ta cũng đã hoàn thành! Một lần nữa, tôi khuyên bạn không nên triển khai thư viện hộp thoại của riêng mình vì nó không đơn giản nhất và lỗi có thể gây ra nhiều vấn đề cho người dùng công nghệ hỗ trợ. Nhưng ít nhất bây giờ bạn đã biết cách nó hoạt động bên dưới!

Nếu bạn cần sử dụng hộp thoại trong dự án của mình, hãy cân nhắc sử dụng một trong các giải pháp sau (xin nhắc lại rằng chúng tôi cũng có danh sách toàn diện các thành phần có thể truy cập được):
Sau đây là những điều khác có thể được thêm vào nhưng không phải vì mục đích đơn giản:
  • Hỗ trợ cho alert-dialogs thông qua vai trò alertdialog. Tham khảo tài liệu về alert dialogs của a11y-dialog a11y-dialog.
  • Khóa khả năng cuộn khi hộp thoại đang mở. Tham khảo tài liệu về scroll lock của a11y-dialog a11y-dialog.
  • Hỗ trợ cho phần tử HTML gốc vì nó kém chất lượng và không nhất quán. Tham khảo tài liệu a11y-dialog về thành phần hộp thoạibài viết này của Scott O’hara để biết thêm thông tin về lý do tại sao không đáng để bận tâm.
  • Hỗ trợ hộp thoại lồng nhau vì nó còn đang gây tranh cãi. Tham khảo tài liệu a11y-dialog về hộp thoại lồng nhau.
  • Cân nhắc khi đóng hộp thoại khi điều hướng trên trình duyệt. Trong một số trường hợp, có thể đóng hộp thoại khi nhấn nút quay lại của trình duyệt là hợp lý.

Đọc thêm​

  • Mẹo và thủ thuật hữu ích của DevTools
  • Thời gian chính xác với API hoạt ảnh web
  • Tạo biểu mẫu nhiều bước hiệu quả để có trải nghiệm người dùng tốt hơn
  • Tầm quan trọng của sự suy giảm duyên dáng trong thiết kế giao diện có thể truy cập
 
Back
Bên trên