گفتگوها در همه جا در طراحی رابط کاربری مدرن (خوب یا بد) وجود دارند ، اما بسیاری از آنها در دسترس فناوری های کمکی نیستند. در این مقاله ، ما به چگونگی ایجاد یک اسکریپت کوتاه برای ایجاد گفتگوهای قابل دسترسی خواهیم پرداخت.
اول از همه ، این کار را در خانه انجام ندهید. برای این کار دیالوگ های خود یا کتابخانه خود را ننویسید. تعداد زیادی از آنها در حال حاضر وجود دارد که مورد آزمایش ، بازرسی ، استفاده و استفاده مجدد قرار گرفته اند و شما باید این موارد را به موارد دیگر خود ترجیح دهید. a11y-dialog یکی از آنها است ، اما تعداد بیشتری وجود دارد (در انتهای این مقاله ذکر شده است).
بگذارید این پست را فرصتی برای یادآوری همه شما بدانم هنگام استفاده از گفتگوها محتاط باشید. رسیدگی به همه مشکلات طراحی با آنها ، به ویژه در تلفن همراه ، امری عادی است ، اما غالباً روش های دیگری برای غلبه بر مشکلات طراحی وجود دارد. ما تمایل داریم به سرعت به استفاده از گفتگوها بپردازیم نه به این دلیل که آنها لزوماً انتخاب درستی هستند بلکه به دلیل آسان بودن آنها هستند. آنها مشکلات املاک صفحه نمایش را با مبادله آنها برای تغییر زمینه کنار می گذارند ، که همیشه معامله مناسب نیست. نکته این است: قبل از استفاده از یک گفتگوی الگوی طراحی مناسب است یا خیر.
در این پست قصد داریم بنویسیم یک کتابخانه کوچک جاوا اسکریپت برای نوشتن گفتگوی قابل دسترسی از همان ابتدا (اساساً بازآفرینی گفتگوی a11y). هدف این است که بفهمیم چه چیزی در آن نقش دارد. ما قصد نداریم زیاد با یک ظاهر طراحی کنیم ، فقط قسمت جاوا اسکریپت. ما از جاوا اسکریپت مدرن به منظور سادگی استفاده می کنیم (مانند کلاسها و توابع پیکان) ، اما به خاطر داشته باشید که این کد ممکن است در مرورگرهای قدیمی کار نکند.
- تعریف API
- مثال زدن دیالوگ
- نشان دادن و پنهان شدن
- بسته شدن با پوشش
- بسته شدن با فرار
- تمرکز به دام انداختن
- حفظ تمرکز
- بازیابی تمرکز
- دادن نام قابل دسترسی
- مدیریت رویدادهای سفارشی
- تمیز کردن
- همه را با هم جمع کنید
- در حال بسته شدن
تعریف API
ابتدا می خواهیم نحوه استفاده از اسکریپت گفتگوی خود را مشخص کنیم. ما می خواهیم برای شروع آن را تا حد ممکن ساده نگه داریم. ما عنصر اصلی HTML را برای گفتگوی خود به آن می دهیم و نمونه ای که دریافت می کنیم دارای یک است .show(..)
و یک .hide(..)
روش.
class Dialog {
constructor(element) {}
show() {}
hide() {}
}
القای گفتگو
بیایید بگوییم که HTML زیر را داریم:
<div id="my-dialog">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)
}
توجه داشته باشید که ما می توانستیم این 3 ویژگی را در HTML اولیه خود اضافه کنیم تا مجبور نباشیم آنها را با JavaScript اضافه کنیم ، اما از این طریق دور از ذهن و ذهن نیست. اسکریپت ما می تواند اطمینان حاصل کند که همه چیز همانطور که باید کار می کند ، صرف نظر از اینکه ما در مورد افزودن همه ویژگی های خود فکر کرده ایم یا خیر.
نمایش و پنهان کردن
ما دو روش داریم: یکی برای نشان دادن گفتگو و دیگری برای پنهان کردن آن. این روش ها علاوه بر تغییر وضعیت ، کار زیادی نمی کنند (در حال حاضر) hidden
ویژگی روی عنصر ریشه ما همچنین می خواهیم نمونه ای بولین را حفظ کنیم تا بتوانیم به سرعت ارزیابی کنیم که آیا گفتگو نشان داده شده است یا خیر. این بعداً مفید خواهد بود.
show() {
this.isShown = true
this.element.removeAttribute('hidden')
}
hide() {
this.isShown = false
this.element.setAttribute('hidden', true)
}
برای جلوگیری از قابل مشاهده بودن گفتگو قبل از شروع JavaScript و پنهان کردن آن با افزودن ویژگی ، ممکن است جالب باشد که اضافه کنید hidden
به گفتگوی مستقیم در HTML از شروع بروید.
<div id="my-dialog" hidden>This will be a dialog.</div>
بستن با روکش
با کلیک بر روی خارج از گفتگو باید آن را ببندید. روش های مختلفی برای انجام این کار وجود دارد. یک راه می تواند گوش دادن به همه رویدادهای کلیک روی صفحه و فیلتر کردن آنچه در این گفتگو رخ می دهد ، باشد ، اما انجام این کار نسبتاً پیچیده است.
رویکرد دیگر گوش دادن به کلیک روی رویدادها روی همپوشانی است (که بعضا “پس زمینه” نیز نامیده می شود). پوشش هم می تواند به همین سادگی باشد <div>
با برخی از سبک ها
بنابراین هنگام باز کردن محاوره ، باید رویدادهای کلیک را روی همپوش قرار دهیم. ما می توانیم به آن شناسه یا کلاس خاصی بدهیم تا بتواند آن را پرس و جو کند ، یا می توانیم یک ویژگی داده به آن بدهیم. من تمایل دارم که این موارد را برای قلاب های رفتاری ترجیح دهم. بیایید HTML خود را بر این اساس اصلاح کنیم:
<div id="my-dialog" 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 id="my-dialog" hidden>
<div data-dialog-hide></div>
<div>
This will be a dialog.
<button type="button" data-dialog-hide>Close</button>
</div>
</div>
بسته شدن با فرار
هنگام کلیک کردن در خارج از آن ، نه تنها باید گفتگو پنهان شود ، بلکه هنگام فشار دادن باید پنهان شود خروج. هنگام باز کردن گفتگو ، می توانیم یک شنونده صفحه کلید را به سند متصل کنیم ، و هنگام بستن آن را حذف کنیم. به این ترتیب ، فقط وقتی کلیدها بجای همه وقت باز است ، فشارهای کلید را گوش می کند.
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()
}
به دام انداختن تمرکز
حالا این چیزهای خوب است. به دام انداختن تمرکز درون محاوره به نوعی در اصل کل موضوع است و باید پیچیده ترین قسمت باشد (هرچند احتمالاً آنقدرها هم که فکر می کنید پیچیده نیست).
ایده بسیار ساده است: وقتی گفتگو باز است ، ما به آن گوش می دهیم زبانه پرس می کند در صورت فشار دادن زبانه در آخرین عنصر قابل تمرکز گفتگو ، ما به طور برنامه ریزی شده تمرکز را به اولین حرکت می دهیم. در صورت فشار دادن تغییر مکان + زبانه در اولین عنصر قابل تمرکز گفتگو ، آن را به آخرین مورد منتقل می کنیم.
عملکرد ممکن است به صورت زیر باشد:
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()
}
هنگام پنهان کردن گفتگو ، می توانیم تمرکز را به آن عنصر برگردانیم. ما آن را با شرطی محافظت می کنیم تا در صورت عدم وجود عنصر (یا اگر SVG بود):
hide() {
// … rest of the code
if (this.previouslyFocused && this.previouslyFocused.focus) {
this.previouslyFocused.focus()
}
}
دادن نام قابل دسترسی
مهم این است که گفتگوی ما دارای نامی قابل دسترسی باشد ، به این ترتیب در درخت دسترسی قابل ذکر است. دو راه برای پرداختن به آن وجود دارد ، یکی از آنها تعریف یک نام در aria-label
صفت ، اما aria-label
مسائلی دارد.
راه دیگر این است که یک عنوان در محاوره خود داشته باشیم (چه پنهان باشد چه نه) و گفتگوی خود را با آن مرتبط کنیم aria-labelledby
صفت. ممکن است به این شکل باشد:
<div id="my-dialog" hidden aria-labelledby="my-dialog-title">
<div data-dialog-hide></div>
<div>
<h1 id="my-dialog-title">My dialog title</h1>
This will be a dialog.
<button type="button" data-dialog-hide>Close</button>
</div>
</div>
من حدس می زنم که بتوانیم اسکریپت خود را بر اساس وجود عنوان و موارد دیگر ، به صورت پویا از این ویژگی استفاده کنیم ، اما من می گویم که برای شروع این کار با نوشتن HTML مناسب به همین راحتی حل می شود. نیازی به افزودن جاوا اسکریپت برای آن نیست.
مدیریت رویدادهای سفارشی
اگر بخواهیم به باز بودن گفتگو واکنش نشان دهیم چه می کنیم؟ یا بسته؟ در حال حاضر راهی برای انجام این کار وجود ندارد ، اما افزودن یک سیستم رویداد کوچک نباید خیلی دشوار باشد. ما برای ثبت رویدادها به یک تابع نیاز داریم (اجازه دهید آن را صدا کنیم .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)
}
بسته بندی کردن
این کاملاً چیزی بود ، اما در نهایت به آنجا رسیدیم! یکبار دیگر ، توصیه می کنم کتابخانه گفتگوی خود را راه اندازی نکنید زیرا این کتاب ساده ترین نیست و خطاها می تواند برای کاربران فناوری کمکی بسیار مشکل ساز باشد. اما حداقل اکنون می دانید که زیر کاپوت چگونه کار می کند!
اگر لازم است از گفتگوها در پروژه خود استفاده کنید ، استفاده از یکی از راه حل های زیر را در نظر بگیرید (یادآوری مهربانانه که لیست کاملی از اجزای قابل دسترسی را نیز در اختیار داریم):
در اینجا چیزهای بیشتری وجود دارد که می تواند اضافه شود اما به دلیل سادگی نیست:
