如何开发无障碍Modal Dialog

30 阅读2分钟

1.开发规范

  1. 焦点管理
    1. 进入模态框后,页面的焦点应聚焦于模态框内第一个可聚焦的焦点上
    2. 在模态框内,tab切换需要实现循环。(不能出现与底层元素交互的行为)
    3. 退出模态框后,焦点应该聚焦于当前元素或者下一个元素()
  2. 键盘交互
    1. esc:退出
    2. tab:正向遍历
    3. shift+tab:反向遍历
    4. enter:激活
  3. 模态框的相关aria属性需要设置

2.代码详解

  1. 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>
        );
    }
}
  1. 使用第三方库实现(主体代码与上文一致,焦点循环部分使用第三方库)
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>
        );
    }
}

参考链接

张赐荣:在网页中如何创建一个无障碍的模态对话框 大佬写的超级好,提供了特别好的思路

我在大神原有代码上增加了

  1. 进入模态框时,焦点定位于第一个可聚焦的元素上。解决:When the modal dialog is activated, keyboard focus is not placed on/in it. dq官网链接:docs.deque.com/issue-help/…
  2. 进入模态框时,除模态框外的其他元素设置 aria-hidden 属性,解决:Screen readers can read content outside the modal dialog. dq官网链接:docs.deque.com/issue-he