1.开发规范
- 焦点管理
- 进入模态框后,页面的焦点应聚焦于模态框内第一个可聚焦的焦点上
- 在模态框内,tab切换需要实现循环。(不能出现与底层元素交互的行为)
- 退出模态框后,焦点应该聚焦于当前元素或者下一个元素()
- 键盘交互
- esc:退出
- tab:正向遍历
- shift+tab:反向遍历
- enter:激活
- 模态框的相关aria属性需要设置
2.代码详解
- JS原生事件实现。使用react的类组件
import React from 'react';
import { BasePage } from '@ctrip/nfes';
import './style/dialog.scss';
export default class IndexPage extends BasePage {
constructor(props) {
super(props);
this.focusTapping = this.focusTapping.bind(this);
this.state = {
isOpen: false,
previouslyFocusedElement: null,
};
}
handleModalOpen = () => {
this.setState({ isOpen: true, previouslyFocusedElement: document.activeElement }, () => {
const modal = document.querySelector('.modal');
const mainContent = document.querySelector('.main-content');
mainContent.setAttribute('aria-hidden', true);
const focusableElements = modal.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
const firstFocusableElement = focusableElements[0];
firstFocusableElement.focus();
});
};
handleModalClose = () => {
this.setState({ isOpen: false });
const previouslyFocusedElement = this.state.previouslyFocusedElement;
previouslyFocusedElement.focus();
const mainContent = document.querySelector('.main-content');
mainContent.setAttribute('aria-hidden', false);
};
handleEscModalClose = (e) => {
if (e.key === 'Esc' || e.key === 'Escape') {
this.handleModalClose();
}
};
componentDidMount() {
window.addEventListener('keydown', this.handleEscModalClose);
window.addEventListener('keydown', this.focusTapping);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleEscModalClose);
window.removeEventListener('keydown', this.focusTapping);
}
// 焦点循环
focusTapping(e) {
const { isOpen } = this.state;
if (!isOpen) return;
const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) return;
const modal = document.querySelector('.modal');
const focusableElements = modal.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusableElement) {
lastFocusableElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
}
}
}
render() {
const { isOpen } = this.state;
return (
<React.Fragment>
<div className='main-content'>
<div tabIndex={0}>Test Modal Dialog</div>
<div>
<a href='https://baidu.com'>Baidu测试链接</a>
</div>
<div tabIndex={0}>弹窗外的文字,点击下面的按钮可以打开弹窗</div>
<button
role='button'
onClick={this.handleModalOpen}
>
打开弹窗
</button>
<div tabIndex={0}>底部文字TEST1</div>
<div tabIndex={0}>底部文字TEST2</div>
</div>
{isOpen && (
<div
className='modal'
role='alertdialog'
aria-label='Title'
aria-describedby='content'
aria-modal='true'
>
<div className='modal-content'>
<h2 tabIndex={0}>Dialog Title</h2>
<p tabIndex={0}>Dialog Content Test</p>
<div>
<button
className='confirm'
onClick={this.handleModalClose}
>
确定
</button>
<button
className='cancel'
onClick={this.handleModalClose}
>
取消
</button>
</div>
</div>
</div>
)}
</React.Fragment>
);
}
}
- 使用第三方库实现(主体代码与上文一致,焦点循环部分使用第三方库)
import React from 'react';
import { BasePage } from '@ctrip/nfes';
import './style/dialog.scss';
import FocusLock from 'react-focus-lock';
export default class IndexPage extends BasePage {
constructor(props) {
super(props);
this.state = {
isOpen: false,
previouslyFocusedElement: null,
};
}
handleModalOpen = () => {
this.setState({ isOpen: true, previouslyFocusedElement: document.activeElement }, () => {
const mainContent = document.querySelector('.main-content');
mainContent.setAttribute('aria-hidden', true);
});
};
handleModalClose = () => {
this.setState({ isOpen: false });
const previouslyFocusedElement = this.state.previouslyFocusedElement;
previouslyFocusedElement.focus();
const mainContent = document.querySelector('.main-content');
mainContent.setAttribute('aria-hidden', false);
};
handleEscModalClose = (e) => {
if (e.key === 'Esc' || e.key === 'Escape') {
this.handleModalClose();
}
};
componentDidMount() {
window.addEventListener('keydown', this.handleEscModalClose);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleEscModalClose);
}
render() {
const { isOpen } = this.state;
return (
<React.Fragment>
<div className='main-content'>
<div tabIndex={0}>Test Modal Dialog</div>
<div>
<a href='https://baidu.com'>Baidu测试链接</a>
</div>
<div tabIndex={0}>弹窗外的文字,点击下面的按钮可以打开弹窗</div>
<button
role='button'
onClick={this.handleModalOpen}
>
打开弹窗
</button>
<div tabIndex={0}>底部文字TEST1</div>
<div tabIndex={0}>底部文字TEST2</div>
</div>
{isOpen && (
<FocusLock>
{' '}
<div
className='modal'
role='alertdialog'
aria-label='Title'
aria-describedby='content'
aria-modal='true'
>
<div className='modal-content'>
<h2 tabIndex={0}>Dialog Title</h2>
<p tabIndex={0}>Dialog Content Test</p>
<div>
<button
className='confirm'
accessKey='o'
onClick={this.handleModalClose}
>
确定
</button>
<button
className='cancel'
accessKey='c'
onClick={this.handleModalClose}
>
取消
</button>
</div>
</div>
</div>
</FocusLock>
)}
</React.Fragment>
);
}
}
参考链接
张赐荣:在网页中如何创建一个无障碍的模态对话框 大佬写的超级好,提供了特别好的思路
我在大神原有代码上增加了
- 进入模态框时,焦点定位于第一个可聚焦的元素上。解决:When the modal dialog is activated, keyboard focus is not placed on/in it. dq官网链接:docs.deque.com/issue-help/…
- 进入模态框时,除模态框外的其他元素设置 aria-hidden 属性,解决:Screen readers can read content outside the modal dialog. dq官网链接:docs.deque.com/issue-he