إنشاء حوار يمكن الوصول إليه من الصفر
نشرت: 2022-03-10بادئ ذي بدء ، لا تفعل هذا في المنزل. لا تكتب الحوارات الخاصة بك أو مكتبة للقيام بذلك. يوجد الكثير منها بالفعل تم اختبارها وتدقيقها واستخدامها وإعادة استخدامها ويجب أن تفضلها على تلك الخاصة بك. a11y- الحوار هو واحد منهم ، ولكن هناك المزيد (المدرجة في نهاية هذه المقالة).
اسمحوا لي أن أغتنم هذا المنشور كفرصة لتذكيركم جميعًا بتوخي الحذر عند استخدام الحوارات . من المثير للاهتمام معالجة جميع مشاكل التصميم معهم ، خاصةً على الهاتف المحمول ، ولكن غالبًا ما توجد طرق أخرى للتغلب على مشكلات التصميم. نحن نميل إلى الوقوع بسرعة في استخدام الحوارات ليس لأنها بالضرورة الاختيار الصحيح ولكن لأنها سهلة. لقد وضعوا جانبًا مشكلات ملكية الشاشة عن طريق استبدالها بتبديل السياق ، وهو ما لا يمثل دائمًا المقايضة الصحيحة. النقطة المهمة هي: النظر فيما إذا كان الحوار هو نمط التصميم الصحيح قبل استخدامه.
في هذا المنشور ، سنقوم بكتابة مكتبة جافا سكريبت صغيرة لتأليف حوارات يمكن الوصول إليها من البداية (بشكل أساسي إعادة إنشاء مربع حوار a11y). الهدف هو فهم ما يدخل فيه. لن نتعامل مع التصميم كثيرًا ، فقط جزء JavaScript. سنستخدم JavaScript حديثًا من أجل البساطة (مثل الفئات ووظائف الأسهم) ، ولكن ضع في اعتبارك أن هذا الرمز قد لا يعمل في المتصفحات القديمة.
- تحديد API
- تجسيد الحوار
- العرض والاختباء
- إغلاق مع تراكب
- الختام مع الهروب
- تركيز محاصرة
- الحفاظ على التركيز
- استعادة التركيز
- إعطاء اسم يسهل الوصول إليه
- التعامل مع الأحداث المخصصة
- تنظيف
- اجمعها كلها معا
- تغليف
تحديد API
أولاً ، نريد تحديد كيف سنستخدم البرنامج النصي للحوار الخاص بنا. سنبقيها بسيطة قدر الإمكان لتبدأ بها. نعطيه عنصر HTML الجذر لمربع الحوار الخاص بنا ، والمثال الذي نحصل عليه يحتوي على طريقة .show(..)
وأسلوب .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 الأولي لدينا حتى لا نضطر إلى إضافتها باستخدام JavaScript ، ولكن بهذه الطريقة يكون بعيدًا عن الأنظار ، بعيدًا عن الذهن. يمكن أن يتأكد البرنامج النصي الخاص بنا من أن الأشياء ستعمل كما ينبغي ، بغض النظر عما إذا كنا قد فكرنا في إضافة كل سماتنا أم لا.
إظهار والاختباء
لدينا طريقتان: واحدة لإظهار الحوار وأخرى لإخفائه. لن تفعل هذه الطرق الكثير (في الوقت الحالي) إلى جانب تبديل السمة hidden
على عنصر الجذر. سنقوم أيضًا بالحفاظ على منطقية في المثيل حتى نتمكن بسرعة من تقييم ما إذا كان الحوار معروضًا أم لا. سيكون هذا مفيدًا لاحقًا.
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
لتجنب ظهور مربع الحوار قبل بدء تشغيل JavaScript وإخفائه عن طريق إضافة السمة ، قد يكون من المثير للاهتمام إضافة " hidden
" إلى مربع الحوار مباشرةً في HTML من البداية.
<div hidden>This will be a dialog.</div>
إغلاق مع تراكب
يجب أن يؤدي النقر خارج مربع الحوار إلى إغلاقه. هناك عدة طرق للقيام بذلك. قد تكون إحدى الطرق هي الاستماع إلى جميع أحداث النقر على الصفحة وتصفية تلك التي تحدث داخل مربع الحوار ، ولكن هذا الأمر معقد نسبيًا.
هناك طريقة أخرى تتمثل في الاستماع إلى أحداث النقر على التراكب (تسمى أحيانًا "الخلفية"). يمكن أن يكون التراكب نفسه بسيطًا مثل <div>
مع بعض الأنماط.
لذلك عند فتح مربع الحوار ، نحتاج إلى ربط أحداث النقر على التراكب. يمكننا منحه معرفًا أو فئة معينة حتى يتمكن من الاستعلام عنه ، أو يمكننا إعطائه سمة بيانات. أميل إلى تفضيل هذه السلوكيات. دعنا نعدل 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>
الختام مع الهروب
لا يجب إخفاء مربع الحوار عند النقر خارجه فحسب ، بل يجب إخفاؤه أيضًا عند الضغط على 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 الخاص بالمتصفح ، ثم نبدأ في الجدولة مرة أخرى. لن تعمل مصيدة التركيز الخاصة بنا ، لأنها تحافظ فقط على التركيز داخل مربع الحوار عندما يكون داخل مربع الحوار لتبدأ به.
لإصلاح هذه المشكلة ، يمكننا ربط مستمع التركيز بعنصر <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() }
عند إخفاء مربع الحوار ، يمكننا إعادة التركيز إلى هذا العنصر. نحن نحرسه بشرط لتجنب خطأ JavaScript إذا لم يعد العنصر موجودًا بطريقة ما (أو إذا كان SVG):
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(..)
).
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-Dial by yours really or aria-modal-interview by Scott O'Hara.
- عمليات تنفيذ React: رد فعل-a11y-مربع حوار من قبلك حقًا مرة أخرى ، أو الوصول / الحوار من إطار عمل الوصول ، أو @ رد فعل- aria / الحوار من Adobe. قد تكون مهتمًا بهذه المقارنة بين المكتبات الثلاثة.
- تطبيقات Vue: vue-a11y-الحوار بواسطة Moritz Kroger ، a11y-vue-الحوار بواسطة Renato de Leao.
إليك المزيد من الأشياء التي يمكن إضافتها ولكن ليس من أجل البساطة:
- دعم حوارات التنبيه عبر دور الحوار
alertdialog
. الرجوع إلى وثائق a11y-الحوار في مربعات حوار التنبيهات. - تأمين القدرة على التمرير أثناء فتح مربع الحوار. الرجوع إلى وثائق a11y- مربع الحوار على قفل التمرير.
- دعم عنصر
<dialog>
HTML الأصلي لأنه دون المستوى وغير متسق. ارجع إلى وثائق a11y-الحوار الخاصة بعنصر الحوار وهذه القطعة التي كتبها سكوت أوهارا للحصول على مزيد من المعلومات حول سبب عدم استحقاقها للعناء. - دعم الحوارات المتداخلة لأنها مشكوك فيها. الرجوع إلى وثائق a11y-الحوار في مربعات الحوار المتداخلة.
- النظر في إغلاق مربع الحوار حول التنقل في المتصفح. في بعض الحالات ، قد يكون من المنطقي إغلاق مربع الحوار عند الضغط على زر الرجوع في المتصفح.