浏览代码

feat(a11y): modal #205

走鹃 3 年之前
父节点
当前提交
6cbfacc9cb

+ 18 - 0
packages/semi-foundation/modal/modalContentFoundation.ts

@@ -13,6 +13,7 @@ export interface ModalContentProps extends ModalProps {
 
 export interface ModalContentState {
     dialogMouseDown: boolean;
+    prevFocusElement: HTMLElement;
 }
 
 export interface ModalContentAdapter extends DefaultAdapter<ModalContentProps, ModalContentState> {
@@ -22,6 +23,9 @@ export interface ModalContentAdapter extends DefaultAdapter<ModalContentProps, M
     addKeyDownEventListener: () => void;
     removeKeyDownEventListener: () => void;
     getMouseState: () => boolean;
+    modalDialogFocus: () => void;
+    modalDialogBlur: () => void;
+    prevFocusElementReFocus: () => void;
 }
 
 export default class ModalContentFoundation extends BaseFoundation<ModalContentAdapter> {
@@ -32,6 +36,8 @@ export default class ModalContentFoundation extends BaseFoundation<ModalContentA
 
     destroy() {
         this.handleKeyDownEventListenerUnmount();
+        this.modalDialogBlur();
+        this.prevFocusElementReFocus();
     }
 
     handleDialogMouseDown() {
@@ -73,4 +79,16 @@ export default class ModalContentFoundation extends BaseFoundation<ModalContentA
     close(e: any) {
         this._adapter.notifyClose(e);
     }
+
+    modalDialogFocus() {
+        this._adapter.modalDialogFocus();
+    }
+
+    modalDialogBlur() {
+        this._adapter.modalDialogBlur();
+    }
+
+    prevFocusElementReFocus() {
+        this._adapter.prevFocusElementReFocus();
+    }
 }

+ 16 - 0
packages/semi-ui/_utils/hooks/usePrevFocus.ts

@@ -0,0 +1,16 @@
+import { useState, useEffect } from 'react';
+import { getActiveElement } from '../index';
+import { get, isFunction } from 'lodash-es';
+
+export function usePrevFocus() {
+    const [prevFocusElement, setPrevFocus] = useState<HTMLElement>(getActiveElement());
+
+    useEffect(() => {
+        return function cleanup() {
+            const blur = get(prevFocusElement, 'blur');
+            isFunction(blur) && blur();
+        };
+    }, [prevFocusElement]);
+
+    return [prevFocusElement, setPrevFocus];
+}

+ 4 - 0
packages/semi-ui/_utils/index.ts

@@ -155,3 +155,7 @@ export interface HighLightTextHTMLChunk {
  * @returns boolean
  */
 export const isSemiIcon = (icon: any): boolean => React.isValidElement(icon) && get(icon.type, 'elementType') === 'Icon';
+
+export function getActiveElement() {
+    return document ? document.activeElement as HTMLElement : null;
+}

+ 0 - 1
packages/semi-ui/button/Button.tsx

@@ -107,7 +107,6 @@ export default class Button extends PureComponent<ButtonProps> {
             ),
             type: htmlType,
             'aria-disabled': disabled,
-            'aria-label': type
         };
 
         return (

+ 2 - 0
packages/semi-ui/modal/Modal.tsx

@@ -283,6 +283,7 @@ class Modal extends BaseComponent<ModalReactProps, ModalState> {
             } else {
                 return (
                     <Button
+                        aria-label="cancel"
                         onClick={this.handleCancel}
                         loading={cancelLoading}
                         type="tertiary"
@@ -300,6 +301,7 @@ class Modal extends BaseComponent<ModalReactProps, ModalState> {
                     <div>
                         {getCancelButton(locale)}
                         <Button
+                            aria-label="confirm"
                             type={okType}
                             theme="solid"
                             loading={confirmLoading}

+ 35 - 5
packages/semi-ui/modal/ModalContent.tsx

@@ -13,8 +13,9 @@ import ModalContentFoundation, {
     ModalContentProps,
     ModalContentState
 } from '@douyinfe/semi-foundation/modal/modalContentFoundation';
-import { noop } from 'lodash-es';
+import { noop, isFunction, get } from 'lodash-es';
 import { IconClose } from '@douyinfe/semi-icons';
+import { getActiveElement } from '../_utils';
 
 let uuid = 0;
 
@@ -37,14 +38,17 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
     dialogId: string;
     private timeoutId: NodeJS.Timeout;
 
-
+    modalDialogRef: React.MutableRefObject<HTMLDivElement>;
+    foundation: ModalContentFoundation;
     constructor(props: ModalContentProps) {
         super(props);
         this.state = {
             dialogMouseDown: false,
+            prevFocusElement: getActiveElement(),
         };
         this.foundation = new ModalContentFoundation(this.adapter);
         this.dialogId = `dialog-${uuid++}`;
+        this.modalDialogRef = React.createRef();
     }
 
     get adapter(): ModalContentAdapter {
@@ -75,11 +79,30 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
                 }
             },
             getMouseState: () => this.state.dialogMouseDown,
+            modalDialogFocus: () => {
+                let activeElementInDialog;
+                if (this.modalDialogRef) {
+                    const activeElement = getActiveElement();
+                    activeElementInDialog = this.modalDialogRef.current.contains(activeElement);
+                }
+                if (!activeElementInDialog) {
+                    this.modalDialogRef && this.modalDialogRef.current.focus();
+                }
+            },
+            modalDialogBlur: () => {
+                this.modalDialogRef && this.modalDialogRef.current.blur();
+            },
+            prevFocusElementReFocus: () => {
+                const { prevFocusElement } = this.state;
+                const focus = get(prevFocusElement, 'focus');
+                isFunction(focus) && prevFocusElement.focus();
+            }
         };
     }
 
     componentDidMount() {
         this.foundation.handleKeyDownEventListenerMount();
+        this.foundation.modalDialogFocus();
     }
 
     componentWillUnmount() {
@@ -132,6 +155,7 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
             const iconType = closeIcon || <IconClose/>;
             closer = (
                 <Button
+                    aria-label="close"
                     className={`${cssClasses.DIALOG}-close`}
                     key="close-btn"
                     onClick={this.close}
@@ -162,7 +186,7 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
             (
                 <div className={`${cssClasses.DIALOG}-header`}>
                     {icon}
-                    <Typography.Title heading={5} className={`${cssClasses.DIALOG}-title`}>{title}</Typography.Title>
+                    <Typography.Title heading={5} className={`${cssClasses.DIALOG}-title`} id={`${cssClasses.DIALOG}-title`}>{title}</Typography.Title>
                     {closer}
                 </div>
             );
@@ -181,7 +205,7 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
         const icon = this.renderIcon();
         const hasHeader = title !== null && title !== undefined || 'header' in this.props;
         return hasHeader ?
-            <div className={bodyCls} style={bodyStyle}>{children}</div> :
+            <div className={bodyCls} id={`${cssClasses.DIALOG}-body`} style={bodyStyle}>{children}</div> :
             (
                 <div className={`${cssClasses.DIALOG}-body-wrapper`}>
                     {icon}
@@ -214,6 +238,7 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
         const header = this.renderHeader();
         const footer = props.footer ? <div className={`${cssClasses.DIALOG}-footer`}>{props.footer}</div> : null;
         const dialogElement = (
+            // eslint-disable-next-line jsx-a11y/no-static-element-interactions
             <div
                 key="dialog-element"
                 className={digCls}
@@ -222,6 +247,11 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
                 id={this.dialogId}
             >
                 <div
+                    role="dialog"
+                    ref={this.modalDialogRef}
+                    aria-modal="true"
+                    aria-labelledby={`${cssClasses.DIALOG}-title`}
+                    aria-describedby={`${cssClasses.DIALOG}-body`}
                     onAnimationEnd={props.onAnimationEnd}
                     className={cls([`${cssClasses.DIALOG}-content`,
                         props.contentClassName,
@@ -257,7 +287,7 @@ export default class ModalContent extends BaseComponent<ModalContentProps, Modal
             <div className={classList}>
                 {this.getMaskElement()}
                 <div
-                    role="modal"
+                    role="none"
                     tabIndex={-1}
                     className={`${cssClasses.DIALOG}-wrap`}
                     onClick={maskClosable ? this.onMaskClick : null}