처음부터 접근 가능한 대화 상자 만들기
게시 됨: 2022-03-10우선 집에서 하지 마세요. 그렇게 하기 위해 자신의 대화 상자나 라이브러리를 작성하지 마십시오. 이미 테스트, 감사, 사용 및 재사용된 것들이 많이 있으며 자신의 것보다 이러한 것을 선호해야 합니다. a11y-dialog가 그 중 하나이지만 더 많은 것이 있습니다(이 기사의 끝에 나열됨).
이 포스트를 여러분 모두가 대화 상자를 사용할 때 주의해야 함 을 상기시키는 기회로 삼겠습니다. 특히 모바일에서 모든 디자인 문제를 해결하는 것이 쉽지만 디자인 문제를 극복하는 다른 방법이 있는 경우가 많습니다. 우리는 대화가 반드시 올바른 선택이기 때문이 아니라 쉽기 때문에 대화를 사용하는 데 빠르게 빠지는 경향이 있습니다. 그들은 항상 올바른 트레이드 오프가 아닌 컨텍스트 전환과 교환하여 화면 자산 문제를 제쳐두고 있습니다. 요점은 대화 상자를 사용하기 전에 대화 상자가 올바른 디자인 패턴인지 고려하는 것입니다.
이 게시물에서 우리는 접근 가능한 대화 상자를 처음부터 작성하기 위한 작은 JavaScript 라이브러리 를 작성할 것입니다(본질적으로 a11y-dialog 재생성). 목표는 그 안에 무엇이 들어 있는지 이해하는 것입니다. 우리는 스타일을 너무 많이 다루지 않고 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) }
JavaScript로 추가할 필요가 없도록 초기 HTML에 이 3가지 속성을 추가할 수 있었지만 이렇게 하면 눈에 띄지 않고 마음에 들지 않습니다. 우리의 스크립트는 모든 속성을 추가할지 여부에 관계없이 모든 것이 제대로 작동하는지 확인할 수 있습니다.
보이기와 숨기기
대화 상자를 표시하는 방법과 숨기는 방법의 두 가지 방법이 있습니다. 이 메서드는 루트 요소의 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>
탈출로 닫기
대화 상자 외부를 클릭할 때 숨겨져야 할 뿐만 아니라 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 키를 누르면 프로그래밍 방식으로 포커스를 첫 번째 요소로 이동합니다. 대화 상자의 첫 번째 포커스 가능한 요소에서 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
)를 가져오는 방법입니다. 이론적으로 초점을 맞출 수 있는 모든 요소를 쿼리한 다음 효과적으로 초점을 맞출 수 있는지 확인해야 합니다.
첫 번째 부분은 focusable-selector로 수행할 수 있습니다. 이 선택기 배열을 제공하는 내가 작성한 아주 작은 패키지입니다.
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
함수를 가지고 있다는 사실에 의해 더 쉬워지기 때문에 우리가 여기서 하는 일입니다. - 닫기 버튼에 초점을 맞춥니다.
이것은 특히 버튼이 대화 상자에 상대적으로 절대적으로 위치하는 경우에 좋은 솔루션입니다. 닫기 버튼을 대화 상자의 첫 번째 요소로 배치하여 편리하게 이를 수행할 수 있습니다. 닫기 버튼이 대화 상자 콘텐츠의 흐름에 있는 경우 맨 끝에 대화 상자에 콘텐츠가 많으면(따라서 스크롤 가능) 문제가 될 수 있습니다. 열 때 콘텐츠가 끝까지 스크롤되기 때문입니다. - 대화 자체에 초점을 맞춥니다 .
이것은 대화 상자 라이브러리에서 그리 일반적이지는 않지만 작동해야 합니다(<div>
요소가 기본적으로 포커스를 받을 수 없기 때문에 가능하도록tabindex="-1"
을 추가해야 하지만).
대화 상자 내에 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를 추가할 필요가 없습니다.
사용자 정의 이벤트 처리
열려 있는 대화 상자에 반응하려면 어떻게 해야 합니까? 아니면 폐쇄? 현재로서는 방법이 없지만 작은 이벤트 시스템을 추가하는 것은 그리 어렵지 않을 것입니다. 이벤트를 등록하는 함수( .off(..)
.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) }
마무리
그것은 상당한 일이었지만 우리는 결국 거기에 도착했습니다! 다시 한번 말하지만, 가장 간단하지 않고 오류가 보조 기술 사용자에게 매우 문제가 될 수 있으므로 자신의 대화 라이브러리를 롤아웃하지 않는 것이 좋습니다. 그러나 적어도 이제 당신은 그것이 후드 아래에서 어떻게 작동하는지 알고 있습니다!
프로젝트에서 대화 상자를 사용해야 하는 경우 다음 솔루션 중 하나를 사용하는 것이 좋습니다.
- 바닐라 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 설명서를 참조하십시오.
- 브라우저 탐색 시 대화 상자 닫기에 대한 고려 사항입니다. 어떤 경우에는 브라우저의 뒤로 버튼을 누를 때 대화 상자를 닫는 것이 합리적일 수 있습니다.