Browse Source

feat(a11y): modal keyboard focus (#837)

代强 3 years ago
parent
commit
ab896e8514

+ 18 - 0
content/show/modal/index-en-US.md

@@ -60,6 +60,7 @@ class modalDemo extends React.Component {
                     onOk={this.handleOk}
                     afterClose={this.handleAfterClose} // >= 1.16.0
                     onCancel={this.handleCancel}
+                    closeOnEsc={true}
                 >
                     This is the content of a basic modal.
                     <br/>
@@ -651,6 +652,23 @@ You could use Modal.destroyAll() to destroy Modal that created by methods above
 -   `Modal.useModal` **v>=1.2.0**  
 When you need access Context, you could use `Modal.useModal` to create a `contextHolder` and insert to corresponding DOM tree. Modal created by hooks will be able to access the context where `contextHolder` is inserted. Hook modal shares the same methods with Modal.method.
 
+
+## Accessibility
+
+### ARIA
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
+- Modal role set to `dialog`
+- aria-modal is set to true
+- aria-labelledby corresponds to Modal header
+- aria-describedby corresponds to Modal body
+
+### Keyboard and focus
+- Modal automatically gets the focus when it is popped up, and when it is closed, the focus automatically returns to the element before it was opened.
+- Keyboard users can use the `Tab` key and `Shift + Tab` to move the focus within the Modal, including the Modal's own close button and OK cancel button. At this time, the elements behind the Modal cannot be tab-focused.
+- When Modal is opened, the focus is on the cancel button by default, which can be controlled by passing autoFocus in cancelButtonProps or okButtonProps.
+- By adding autoFocus to the form element that needs to be focused in the Modal content, the Modal can automatically focus on the element when it is opened (the autoFocus of cancelButtonProps needs to be set to false at the same time).
+- Modify the default value of closeOnEsc to true, allowing users to directly close Modal through the keyboard for a better experience
+
 ## Design Tokens
 <DesignToken/>
 

+ 19 - 0
content/show/modal/index.md

@@ -60,6 +60,7 @@ class modalDemo extends React.Component {
                     onOk={this.handleOk}
                     afterClose={this.handleAfterClose} //>=1.16.0
                     onCancel={this.handleCancel}
+                    closeOnEsc={true}
                 >
                     This is the content of a basic modal.
                     <br/>
@@ -658,6 +659,24 @@ modal.destroy();
 -   `Modal.useModal` **v>=1.2.0**  
 当你需要使用 Context 时,可以通过 Modal.useModal 创建一个 contextHolder 插入相应的节点中。此时通过 hooks 创建的 Modal 将会得到 contextHolder 所在位置的所有上下文。创建的 modal 对象拥有与 [Modal.method](#Modal.method()) 相同的创建通知方法。
 
+## Accessibility
+
+### ARIA
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
+- role 设置为 `dialog`
+- aria-modal 设置为 true
+- aria-labelledby 对应 Modal header
+- aria-describedby 对应 Modal body
+
+### 键盘和焦点
+- Modal 在弹出时自动获得焦点,关闭时焦点自动回归到打开前元素。
+- 键盘用户可以使用 `Tab` 键和 `Shift + Tab`,将焦点在 Modal 内移动,包括 Modal 自带的关闭按钮和确定取消按钮,此时 Modal 背后元素不可被 tab 聚焦。
+- Modal 打开时默认聚焦到取消按钮, 可通过在 cancelButtonProps 或 okButtonProps 传入 autoFocus 来控制该行为。
+- 可通过在 Modal 内容中需要聚焦的表单元素上添加 autoFocus 来让 Modal 打开时自动聚焦到该元素 (需同时设置 cancelButtonProps 的 autoFocus 为 false)。
+- 修改 closeOnEsc 默认值为 true,允许用户通过键盘直接关闭 Modal 带来更好的体验
+
+
+
 ## 设计变量
 <DesignToken/>
 

+ 17 - 1
cypress/integration/modal.spec.js

@@ -32,4 +32,20 @@ describe('modal', () => {
         cy.get(".semi-modal").should("not.exist");
         cy.get(".semi-tag").first().contains("true");
     });
-});
+
+    it.only('useModal FocusTrap',()=>{
+        cy.visit("http://localhost:6006/iframe.html?id=modal--default&viewMode=story");
+        cy.get(".semi-button").click();
+        cy.get('input').should('be.focused');
+
+        cy.get('input').tab();
+        cy.contains('hide dialog').should('be.focused');
+        cy.contains('确定').focus();
+        cy.contains('确定').tab();
+
+        cy.get('button[aria-label=close]').should('be.focused');
+        cy.get('button[aria-label=close]').tab({ shift:true });
+        cy.contains('确定').should('be.focused');
+
+    });
+});

+ 1 - 0
cypress/support/index.js

@@ -17,5 +17,6 @@
 import './commands';
 import '@cypress/code-coverage/support';
 
+require('cypress-plugin-tab');
 // Alternatively you can use CommonJS syntax:
 // require('./commands')

+ 1 - 0
package.json

@@ -152,6 +152,7 @@
     "crypto": "^1.0.1",
     "css-loader": "^3.6.0",
     "cypress": "9.5.2",
+    "cypress-plugin-tab": "^1.0.5",
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.6",
     "enzyme-to-json": "^3.6.2",

+ 159 - 0
packages/semi-foundation/utils/FocusHandle.ts

@@ -0,0 +1,159 @@
+import { isHTMLElement } from "@douyinfe/semi-foundation/utils/dom";
+import { without } from "lodash-es";
+
+
+type FocusRedirectListener = (element: HTMLElement) => boolean;
+
+interface HandleOptions {
+    enable?: boolean
+    onFocusRedirectListener?: FocusRedirectListener | FocusRedirectListener[]
+}
+
+/*
+* Usage:
+*   // Eg1: Pass a dom as the tab tarp container.
+*  const handle = new FocusTrapHandle(container, { enable: true });
+*
+*   // Eg2: The focus redirect listener will be triggered when user pressed tab whiling last focusable dom is focusing in trap dom, return false to cancel redirect and use the browser normal tab focus index.
+*   handle.addFocusRedirectListener((e)=>{
+*       return true; // return false to prevent redirect on target DOM;
+*   });
+*
+*   // Eg3: Set it to false in order to disable tab tarp at any moment;
+*   handle.enable = true;
+*
+*   // Eg4: Destroy instance when component is unmounting for saving resource;
+*   handle.destroy();
+*
+* */
+
+class FocusTrapHandle {
+    public container: HTMLElement;
+    private options: HandleOptions;
+    private focusRedirectListenerList: FocusRedirectListener[];
+    private _enable: boolean;
+
+    constructor(container: HTMLElement, options?: HandleOptions) {
+        Object.freeze(options); // prevent user to change options after init;
+        this.container = container;
+        this.options = options;
+        this.enable = options?.enable ?? true;
+        this.focusRedirectListenerList = (() => {
+            if (options?.onFocusRedirectListener) {
+                return Array.isArray(options.onFocusRedirectListener) ? [...options.onFocusRedirectListener] : [options.onFocusRedirectListener];
+            } else {
+                return [];
+            }
+        })();
+        this.container.addEventListener('keydown', this.onKeyPress);
+    }
+
+    public addFocusRedirectListener = (listener: FocusRedirectListener) => {
+        this.focusRedirectListenerList.push(listener);
+        return () => this.removeFocusRedirectListener(listener);
+    }
+
+    public removeFocusRedirectListener = (listener: FocusRedirectListener) => {
+        this.focusRedirectListenerList = without(this.focusRedirectListenerList, listener);
+    }
+
+    public get enable() {
+        return this._enable;
+    }
+
+    public set enable(value) {
+        this._enable = value;
+    }
+
+    public destroy = () => {
+        this.container?.removeEventListener('keydown', this.onKeyPress);
+    }
+
+    // ---- private func ----
+
+    private shouldFocusRedirect = (element: HTMLElement) => {
+        if (!this.enable) {
+            return false;
+        }
+        for (const listener of this.focusRedirectListenerList) {
+            const should = listener(element);
+            if (!should) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private focusElement = (element: HTMLElement, event: KeyboardEvent) => {
+        element?.focus();
+        event.preventDefault(); // prevent browser default tab move behavior
+    }
+
+
+    private onKeyPress = (event: KeyboardEvent) => {
+        if (event && event.key === 'Tab') {
+            const focusableElements = FocusTrapHandle.getFocusableElements(this.container);
+            const focusableNum = focusableElements.length;
+            if (focusableNum) {
+                // Shift + Tab will move focus backward
+                if (event.shiftKey) {
+                    this.handleContainerShiftTabKeyDown(focusableElements, event);
+                } else {
+                    this.handleContainerTabKeyDown(focusableElements, event);
+                }
+            }
+        }
+    }
+
+    private handleContainerTabKeyDown = (focusableElements: any[], event: any) => {
+        const activeElement = FocusTrapHandle.getActiveElement();
+        const isLastCurrentFocus = focusableElements[focusableElements.length - 1] === activeElement;
+
+        const redirectForcingElement = focusableElements[0];
+        if (isLastCurrentFocus && this.shouldFocusRedirect(redirectForcingElement)) {
+            this.focusElement(redirectForcingElement, event);
+        }
+    };
+
+
+    private handleContainerShiftTabKeyDown = (focusableElements: any[], event: KeyboardEvent) => {
+        const activeElement = FocusTrapHandle.getActiveElement();
+        const isFirstCurrentFocus = focusableElements[0] === activeElement;
+        const redirectForcingElement = focusableElements[focusableElements.length - 1];
+        if (isFirstCurrentFocus && this.shouldFocusRedirect(redirectForcingElement)) {
+            this.focusElement(redirectForcingElement, event);
+        }
+    };
+
+
+    // ---- static func ----
+
+    static getFocusableElements(node: HTMLElement) {
+        if (!isHTMLElement(node)) {
+            return [];
+        }
+        const focusableSelectorsList = [
+            "input:not([disabled]):not([tabindex='-1'])",
+            "textarea:not([disabled]):not([tabindex='-1'])",
+            "button:not([disabled]):not([tabindex='-1'])",
+            "a[href]:not([tabindex='-1'])",
+            "select:not([disabled]):not([tabindex='-1'])",
+            "area[href]:not([tabindex='-1'])",
+            "iframe:not([tabindex='-1'])",
+            "object:not([tabindex='-1'])",
+            "*[tabindex]:not([tabindex='-1'])",
+            "*[contenteditable]:not([tabindex='-1'])",
+        ];
+        const focusableSelectorsStr = focusableSelectorsList.join(',');
+        // we are not filtered elements which are invisible
+        return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectorsStr));
+    }
+
+    static getActiveElement(): HTMLElement | null {
+        return document ? document.activeElement as HTMLElement : null;
+    }
+
+
+}
+
+export default FocusTrapHandle;

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

@@ -14,7 +14,7 @@ export type Size = 'default' | 'small' | 'large';
 export type Theme = 'solid' | 'borderless' | 'light';
 export type Type = 'primary' | 'secondary' | 'tertiary' | 'warning' | 'danger';
 
-export interface ButtonProps {
+export interface ButtonProps extends Omit<React.InputHTMLAttributes<HTMLButtonElement>, 'onChange' | 'prefix' | 'size' | 'placeholder' | 'onFocus' | 'onBlur'>{
     id?: string;
     block?: boolean;
     circle?: boolean;

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

@@ -103,7 +103,7 @@ class Modal extends BaseComponent<ModalReactProps, ModalState> {
         onOk: noop,
         afterClose: noop,
         maskFixed: false,
-        closeOnEsc: false,
+        closeOnEsc: true,
         size: 'small',
         keepDOM: false,
         lazyRender: true,
@@ -290,6 +290,7 @@ class Modal extends BaseComponent<ModalReactProps, ModalState> {
                         onClick={this.handleCancel}
                         loading={cancelLoading}
                         type="tertiary"
+                        autoFocus={true}
                         {...this.props.cancelButtonProps}
                     >
                         {cancelText || locale.cancel}

+ 14 - 8
packages/semi-ui/modal/ModalContent.tsx

@@ -13,9 +13,9 @@ import ModalContentFoundation, {
     ModalContentProps,
     ModalContentState
 } from '@douyinfe/semi-foundation/modal/modalContentFoundation';
-import { noop, isFunction, get } from 'lodash';
+import { get, isFunction, noop } from 'lodash';
 import { IconClose } from '@douyinfe/semi-icons';
-import { getActiveElement } from '../_utils';
+import FocusTrapHandle from "@douyinfe/semi-foundation/utils/FocusHandle";
 
 let uuid = 0;
 
@@ -23,6 +23,7 @@ let uuid = 0;
 export interface ModalContentReactProps extends ModalContentProps {
     children?: React.ReactNode;
 }
+
 export default class ModalContent extends BaseComponent<ModalContentReactProps, ModalContentState> {
     static contextType = ConfigContext;
     static propTypes = {
@@ -45,12 +46,13 @@ export default class ModalContent extends BaseComponent<ModalContentReactProps,
     modalDialogRef: React.MutableRefObject<HTMLDivElement>;
     foundation: ModalContentFoundation;
     context: ContextValue;
+    focusTrapHandle: FocusTrapHandle;
 
     constructor(props: ModalContentProps) {
         super(props);
         this.state = {
             dialogMouseDown: false,
-            prevFocusElement: getActiveElement(),
+            prevFocusElement: FocusTrapHandle.getActiveElement(),
         };
         this.foundation = new ModalContentFoundation(this.adapter);
         this.dialogId = `dialog-${uuid++}`;
@@ -88,15 +90,18 @@ export default class ModalContent extends BaseComponent<ModalContentReactProps,
             modalDialogFocus: () => {
                 let activeElementInDialog;
                 if (this.modalDialogRef) {
-                    const activeElement = getActiveElement();
+                    const activeElement = FocusTrapHandle.getActiveElement();
                     activeElementInDialog = this.modalDialogRef.current.contains(activeElement);
+                    this.focusTrapHandle?.destroy();
+                    this.focusTrapHandle = new FocusTrapHandle(this.modalDialogRef.current);
                 }
                 if (!activeElementInDialog) {
-                    this.modalDialogRef && this.modalDialogRef.current.focus();
+                    this.modalDialogRef?.current?.focus();
                 }
             },
             modalDialogBlur: () => {
-                this.modalDialogRef && this.modalDialogRef.current.blur();
+                this.modalDialogRef?.current.blur();
+                this.focusTrapHandle?.destroy();
             },
             prevFocusElementReFocus: () => {
                 const { prevFocusElement } = this.state;
@@ -192,7 +197,8 @@ export default class ModalContent extends BaseComponent<ModalContentReactProps,
             (
                 <div className={`${cssClasses.DIALOG}-header`}>
                     {icon}
-                    <Typography.Title heading={5} className={`${cssClasses.DIALOG}-title`} id={`${cssClasses.DIALOG}-title`}>{title}</Typography.Title>
+                    <Typography.Title heading={5} className={`${cssClasses.DIALOG}-title`}
+                        id={`${cssClasses.DIALOG}-title`}>{title}</Typography.Title>
                     {closer}
                 </div>
             );
@@ -255,6 +261,7 @@ export default class ModalContent extends BaseComponent<ModalContentReactProps,
                 <div
                     role="dialog"
                     ref={this.modalDialogRef}
+                    tabIndex={-1}
                     aria-modal="true"
                     aria-labelledby={`${cssClasses.DIALOG}-title`}
                     aria-describedby={`${cssClasses.DIALOG}-body`}
@@ -304,7 +311,6 @@ export default class ModalContent extends BaseComponent<ModalContentReactProps,
             </div>
         );
 
-        // @ts-ignore Unreachable branch
         // eslint-disable-next-line max-len
         return containerContext && containerContext.Provider ?
             <containerContext.Provider value={containerContext.value}>{elem}</containerContext.Provider> : elem;

+ 25 - 0
yarn.lock

@@ -5977,6 +5977,14 @@ ajv@^8.0.1:
     require-from-string "^2.0.2"
     uri-js "^4.2.2"
 
+ally.js@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/ally.js/-/ally.js-1.4.1.tgz#9fb7e6ba58efac4ee9131cb29aa9ee3b540bcf1e"
+  integrity sha512-ZewdfuwP6VewtMN36QY0gmiyvBfMnmEaNwbVu2nTS6zRt069viTgkYgaDiqu6vRJ1VJCriNqV0jGMu44R8zNbA==
+  dependencies:
+    css.escape "^1.5.0"
+    platform "1.3.3"
+
 alphanum-sort@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -9186,6 +9194,11 @@ css-what@^6.0.1:
   resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
   integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
 
+css.escape@^1.5.0:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
+  integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
+
 css@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929"
@@ -9327,6 +9340,13 @@ cyclist@^1.0.1:
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
   integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
 
+cypress-plugin-tab@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/cypress-plugin-tab/-/cypress-plugin-tab-1.0.5.tgz#a40714148104004bb05ed62b1bf46bb544f8eb4a"
+  integrity sha512-QtTJcifOVwwbeMP3hsOzQOKf3EqKsLyjtg9ZAGlYDntrCRXrsQhe4ZQGIthRMRLKpnP6/tTk6G0gJ2sZUfRliQ==
+  dependencies:
+    ally.js "^1.4.1"
+
 [email protected]:
   version "9.5.2"
   resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.5.2.tgz#8fb6ee4a890fbc35620800810bf6fb11995927bd"
@@ -19447,6 +19467,11 @@ pkg-dir@^5.0.0:
   dependencies:
     find-up "^5.0.0"
 
[email protected]:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.3.tgz#646c77011899870b6a0903e75e997e8e51da7461"
+  integrity sha1-ZGx3ARiZhwtqCQPnXpl+jlHadGE=
+
 please-upgrade-node@^3.1.1, please-upgrade-node@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"