Browse Source

feat(a11y): popover keyboard (#725)

* feat(a11y): popover keyboard and focus #725

* ci: update cypress image version
走鹃 3 years ago
parent
commit
571960b646

+ 3 - 3
.github/workflows/cypress.yml

@@ -21,7 +21,7 @@ on:
 jobs:
   install:
     runs-on: ubuntu-latest
-    container: cypress/browsers:node14.16.0-chrome90-ff88
+    container: cypress/browsers:node16.5.0-chrome94-ff93
     if: ${{ github.repository_owner == 'DouyinFE' }}
     steps:
       - name: Checkout
@@ -46,7 +46,7 @@ jobs:
           runTests: false
   chrome-tests:
     runs-on: ubuntu-latest
-    container: cypress/browsers:node14.16.0-chrome90-ff88
+    container: cypress/browsers:node16.5.0-chrome94-ff93
     needs: install
     strategy:
       fail-fast: false
@@ -82,7 +82,7 @@ jobs:
   firefox-tests:
     runs-on: ubuntu-latest
     container: 
-      image: cypress/browsers:node14.16.0-chrome90-ff88
+      image: cypress/browsers:node16.5.0-chrome94-ff93
       options: --user 1001
     needs: install
     strategy:

+ 39 - 1
content/show/popover/index-en-US.md

@@ -450,6 +450,32 @@ function Demo() {
 }
 ```
 
+### Initialize the Focus Position of Popup Layer
+
+Popover `content` also supports functions. Its input parameter is an object, which binds `initialFocusRef` to the focusable DOM or component. When the panel is opened, it will automatically focus at that position.
+
+```jsx live=true
+import React from 'react';
+import { Button, Input, Popover, Space } from '@douyinfe/semi-ui';
+() => {
+    const renderContent = ({ initialFocusRef }) => {
+        return (
+            <div style={{ padding: 12 }}>
+                <Space>
+                    <Button>first focusable element</Button>
+                    <Input ref={initialFocusRef} placeholder="focus here" />
+                </Space>
+            </div>
+        );
+    };
+    return (
+        <Popover content={renderContent} trigger="click">
+            <Button>click me</Button>
+        </Popover>
+    );
+};
+```
+
 ### Use with Tooltip or Popconfirm
 
 Please refer to [Use with Tooltip/Popconfirm](/en-US/show/tooltip#%E6%90%AD%E9%85%8D%20Popover%20%E6%88%96%20Popconfirm%20%E4%BD%BF%E7%94%A8)
@@ -460,12 +486,15 @@ Please refer to [Use with Tooltip/Popconfirm](/en-US/show/tooltip#%E6%90%AD%E9%8
 | --- | --- | --- | --- | --- |
 | autoAdjustOverflow | Whether to automatically adjust the expansion direction of the floating layer for automatic adjustment of the expansion direction during edge occlusion | boolean | true |
 | arrowPointAtCenter | Whether the "small triangle" points to the center of the element, you need to pass in "showArrow = true" at the same time | boolean | true | **0.34.0** |
+| closeOnEsc | Whether to close the panel by pressing the Esc key in the trigger or popup layer. It does not take effect when visible is under controlled | boolean | true | **2.8.0** |
 | content | Content displayed | string \| ReactNode |  |
 | clickToHide | Whether to automatically close the elastic layer when clicking on the floating layer and any element inside | boolean | false | **0.24.0** |
 | getPopupContainer | Specifies the parent DOM, and the bullet layer will be rendered to the DOM, you need to set 'position: relative` | () => HTMLElement | () => document.body |
+| guardFocus | When the focus is in the popup layer, toggle whether the Tab makes the focus loop in the popup layer | boolean | true | **2.8.0** |
 | mouseEnterDelay | After the mouse is moved in, the display delay time, in milliseconds (only effective when the trigger is hover/focus) | number | 50 |  |
 | mouseLeaveDelay | The time for the delay to disappear after the mouse is moved out, in milliseconds (only effective when the trigger is hover/focus) | number | 50 |  |
 | rePosKey | You can update the value of this item to manually trigger the repositioning of the pop-up layer | string\|number |  |  |
+| returnFocusOnClose | After pressing the Esc key, whether the focus returns to the trigger, it only takes effect when the trigger is set to click | boolean | true | **2.8.0** |
 | visible | Display popup or not | boolean |  |
 | position | Directions, optional values: `top`, `topLeft`, `topRight`, `leftTop`, `leftBottom`, `rightTop`, `rightTop`, `rightBottom`, `bottomLeft`, `bottomRight`, `bottomRight` | string | "bottom" |
 | spacing | The distance between the out layer and the children element, in px | number | 4(while showArrow=false) 10(while showArrow=true) |  |
@@ -473,8 +502,9 @@ Please refer to [Use with Tooltip/Popconfirm](/en-US/show/tooltip#%E6%90%AD%E9%8
 | trigger | Trigger mode, optional value: `hover`, `focus`, `click`, `custom` | string | 'hover' |
 | stopPropagation | Whether to prevent click events on the bomb layer from bubbling | boolean | false | **0.34.0** |
 | zIndex | Floating layer z-index value | number | 1030 |
-| onVisibleChange | A callback triggered when the pop-up layer is displayed / hidden | (isVisble: boolean) => void |  |
 | onClickOutSide  | Callback when the pop-up layer is in the display state and the non-Children, non-floating layer inner area is clicked (only valid when trigger is custom, click) | (e:event) => void | | **2.1.0** |
+| onEscKeyDown | Called when Esc key is pressed in trigger or popup layer | function(e:event) | | **2.8.0** |
+| onVisibleChange | A callback triggered when the pop-up layer is displayed / hidden | (isVisible: boolean) => void |  |
 
 ## Accessibility
 
@@ -490,6 +520,14 @@ Please refer to [Use with Tooltip/Popconfirm](/en-US/show/tooltip#%E6%90%AD%E9%8
   - Will be automatically added [aria-haspopup](https://www.w3.org/TR/wai-aria-1.1/#aria-haspopup) attribute, which is `dialog`
   - Will be automatically added [aria-controls](https://www.w3.org/TR/wai-aria-1.1/#aria-controls) attribute, which is the id of the content wrapper
 
+### Keyboard and Focus
+
+- When the Popover trigger method is set to `hover`: Open the Popover when the mouse is hovered or focused
+- When the Popover trigger method is set to `click`: Click the trigger or focus and use the Enter key to open the Popover
+- After the Popover is activated, press the `arrow key` ⬇️ to move the focus to the Popover. At this time, the focus is on the first interactive element in the Popover by default, and the user can also customize the focus position (if there is no interactive element in the Popover, it will appear as No response)
+- Use the `Tab` key when the focus is in the Popover, the focus will cycle in the Popover, and use `Shift + Tab` to move the focus in the opposite direction
+- Keyboard users can close the Popover by pressing `Esc`, after closing the focus returns to the trigger (when the trigger is click)
+
 ## Design Tokens
 
 <DesignToken/>

+ 43 - 2
content/show/popover/index.md

@@ -450,22 +450,54 @@ function Demo() {
 }
 ```
 
+### 初始化弹出层焦点位置
+
+Popover content 支持传入函数,它的入参是一个对象,将 `initialFocusRef` 绑定在可聚焦 DOM 或组件上,打开面板时会自动聚焦在该位置。
+
+```jsx live=true
+import React from 'react';
+import { Button, Input, Popover, Space } from '@douyinfe/semi-ui';
+
+() => {
+    const renderContent = ({ initialFocusRef }) => {
+        return (
+            <div style={{ padding: 12 }}>
+                <Space>
+                    <Button>first focusable element</Button>
+                    <Input ref={initialFocusRef} placeholder="focus here" />
+                </Space>
+            </div>
+        );
+    };
+
+    return (
+        <Popover content={renderContent} trigger="click">
+            <Button>click me</Button>
+        </Popover>
+    );
+};
+```
+
 ### 搭配 Tooltip 或 Popconfirm 使用
 
 请参考[搭配使用](/zh-CN/show/tooltip#%E6%90%AD%E9%85%8D%20Popover%20%E6%88%96%20Popconfirm%20%E4%BD%BF%E7%94%A8)
 
+
 ## API 参考
 
 | 属性               | 说明                                                                                                                                        | 类型                       | 默认值                                      | 版本       |
 | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------- | ---------- |
 | autoAdjustOverflow | 是否自动调整弹出层展开方向,用于边缘遮挡时自动调整展开方向                                                                                  | boolean                    | true                                        |            |
 | arrowPointAtCenter | “小三角”是否指向元素中心,需要同时传入"showArrow=true"                                                                                      | boolean                    | true                                        | **0.34.0** |
-| content            | 显示的内容                                                                                                                                  | string\|ReactNode          |                                             |            |
+| closeOnEsc         | 在 trigger 或 弹出层按 Esc 键是否关闭面板,受控时不生效 | boolean | true | **2.8.0** |
+| content            | 显示的内容(函数类型,2.8.0 版本支持)                                                                                                                                  | ReactNode \| ({ initialFocusRef }) => ReactNode          |                                             |            |
 | clickToHide        | 点击弹出层及内部任一元素时是否自动关闭弹层                                                                                                  | boolean                    | false                                       | **0.24.0** |
 | getPopupContainer  | 指定父级 DOM,弹层将会渲染至该 DOM 中,自定义需要设置 `position: relative`                                                                  | function():HTMLElement     | () => document.body                         |            |
+| guardFocus         | 当焦点处于弹出层内时,切换 Tab 是否让焦点在弹出层内循环 | boolean | true | **2.8.0** |
 | mouseEnterDelay    | 鼠标移入后,延迟显示的时间,单位毫秒(仅当 trigger 为 hover/focus 时生效)                                                                  | number                     | 50                                          |            |
 | mouseLeaveDelay    | 鼠标移出后,延迟消失的时间,单位毫秒(仅当 trigger 为 hover/focus 时生效)                                                                  | number                     | 50                                          |            |
 | rePosKey           | 可以更新该项值手动触发弹出层的重新定位                                                                                                         | string\|number             |                                            |             |
+| returnFocusOnClose | 按下 Esc 键后,焦点是否回到 trigger 上,只有设置 trigger 为 click 时生效 | boolean | true  | **2.8.0** |
 | position           | 方向,可选值:`top`,`topLeft`,`topRight`,`left`,`leftTop`,`leftBottom`,`right`,`rightTop`,`rightBottom`,`bottom`,`bottomLeft`,`bottomRight` | string                     | "bottom"                                    |            |
 | spacing            | 弹出层与 children 元素的距离,单位 px                                                                                                       | number                     | 4(showArrow=false 时) 10(showArrow=true 时) |            |
 | showArrow          | 是否显示“小三角”                                                                                                                            | boolean                    |                                             |            |
@@ -473,8 +505,9 @@ function Demo() {
 | trigger            | 触发方式,可选值:`hover`, `focus`, `click`, `custom`                                                                                       | string                     | 'hover'                                     |            |
 | visible            | 是否显示,配合trigger='custom'可实现完全受控                                                                                                                                    | boolean                    |                                             |            |
 | zIndex             | 弹出层 z-index 值                                                                                                                             | number                     | 1030                                        |            |
-| onVisibleChange    | 弹出层展示/隐藏时触发的回调                                                                                                                 | function(isVisble:boolean) |                                             |            |
 | onClickOutSide     | 当弹出层处于展示状态,点击非Children、非浮层内部区域时的回调(仅trigger为custom、click时有效)| function(e:event) |  | **2.1.0** |
+| onEscKeyDown       | 在 trigger 或 弹出层按 Esc 键时调用        | function(e:event) | | **2.8.0** |
+| onVisibleChange    | 弹出层展示/隐藏时触发的回调                                                                                                                 | function(isVisble:boolean) |                                             |            |
 
 ## Accessibility
 
@@ -490,5 +523,13 @@ function Demo() {
   - 会被自动添加 [aria-haspopup](https://www.w3.org/TR/wai-aria-1.1/#aria-haspopup) 属性,为 `dialog`
   - 会被自动添加 [aria-controls](https://www.w3.org/TR/wai-aria-1.1/#aria-controls) 属性,为 content 的 wrapper 的 id
 
+### Keyboard and Focus
+
+- Popover 触发方式设置为 hover 时:鼠标悬浮或聚焦时打开 Popover
+- Popover 触发方式设置为 click 时:点击触发器或聚焦时并使用 Enter 键打开 Popover
+- Popover 激活后,按下方向键 ⬇️ 将焦点移动到 Popover 上,此时焦点默认处于 Popover 中第一个可交互元素上,用户也可自定义焦点位置(若 Popover 内无可交互元素则表现为无响应)
+- 焦点处于 Popover 内时使用 Tab 键,焦点会在 Popover 内循环,使用 Shift + Tab 会反方向移动焦点
+- 键盘用户能够通过按 Esc 关闭 Popover,关闭后焦点返回到触发器上(仅当 trigger 为 click 时)
+
 ## 设计变量
 <DesignToken/>

+ 82 - 0
cypress/integration/popover.spec.js

@@ -0,0 +1,82 @@
+// Start writing your Cypress tests below!
+// If you're unfamiliar with how Cypress works,
+// check out the link below and learn how to write your first test:
+// https://on.cypress.io/writing-first-test
+
+describe('popover', () => {
+    it('trigger=click + keyboard', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=click]').click();
+        cy.get('[data-cy=click]').type('{downArrow}');
+        cy.get('[data-cy=pop-focusable-first]').should('be.focused');
+        cy.get('[data-cy=pop-focusable-first]').type('{esc}');
+        cy.get('[data-cy=click]').should('be.focused');
+        cy.get('[data-cy=click]').click();
+        cy.get('[data-cy=click]').type('{upArrow}');
+        cy.get('[data-cy=pop-focusable-last]').should('be.focused');
+    });
+
+    it('trigger=hover + keyboard', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=hover]').trigger('focus');
+        cy.get('[data-cy=hover]').type('{downArrow}');
+        cy.get('[data-cy=pop-focusable-first]').should('be.focused');
+        cy.get('[data-cy=pop-focusable-first]').type('{esc}');
+        cy.get('[data-cy=hover]').trigger('focus');
+        cy.get('[data-cy=hover]').type('{upArrow}');
+        cy.get('[data-cy=pop-focusable-last]').should('be.focused');
+    });
+
+    it('trigger=focus + keyboard', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=focus]').trigger('focus');
+        cy.get('[data-cy=focus]').type('{downArrow}');
+        cy.get('[data-cy=pop-focusable-first]').should('be.focused');
+        cy.get('[data-cy=pop-focusable-first]').type('{esc}');
+        cy.get('[data-cy=focus]').trigger('focus');
+        cy.get('[data-cy=focus]').type('{upArrow}');
+        cy.get('[data-cy=pop-focusable-last]').should('be.focused');
+    });
+
+    it('trigger=custom + keyboard', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=custom]').trigger('click');
+        cy.get('[data-cy=custom]').type('{downArrow}');
+        cy.get('[data-cy=pop-focusable-first]').should('be.focused');
+        cy.get('[data-cy=pop-focusable-first]').type('{esc}');
+        cy.get('[data-cy=custom]').trigger('click');
+        cy.get('[data-cy=custom]').type('{upArrow}');
+        cy.get('[data-cy=pop-focusable-last]').should('be.focused');
+    });
+
+    it('trigger=click + focus guard', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=click]').click();
+        cy.get('[data-cy=click]').type('{upArrow}');
+        cy.get('[data-cy=pop]').trigger('keydown', { eventConstructor: 'KeyboardEvent', key: 'Tab' });
+        cy.get('[data-cy=pop-focusable-first]').should('be.focused');
+        cy.get('[data-cy=pop]').trigger('keydown', { eventConstructor: 'KeyboardEvent', key: 'Tab', shiftKey: true });
+        cy.get('[data-cy=pop-focusable-last]').should('be.focused');
+    });
+
+    it('trigger=click + init focus', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=initial-focus]').click({ force: true });
+        cy.get('[data-cy=initial-focus-input]').should('be.focused');
+    });
+
+    it('trigger=click + closeOnEsc=false', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=closeOnEsc-false]').click({ force: true });
+        cy.get('[data-cy=closeOnEsc-false]').type('{esc}');
+        cy.get('[data-cy=pop]').should('be.visible');
+    });
+
+    it('trigger=click + returnFocusOnClose=false', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popover--a-11-y-keyboard&args=&viewMode=story');
+        cy.get('[data-cy=returnFocusOnClose-false]').click({ force: true });
+        cy.get('[data-cy=returnFocusOnClose-false]').type('{downArrow}');
+        cy.get('[data-cy=pop-focusable-first]').type('{esc}', { force: true });
+        cy.get('[data-cy=returnFocusOnClose-false]').should('not.be.focused');
+    });
+});

+ 1 - 2
cypress/integration/tooltip.spec.js

@@ -9,6 +9,7 @@
  * Cypress will default scroll element into view
  * @see https://docs.cypress.io/guides/core-concepts/interacting-with-elements#Scrolling
  */
+
 describe('tooltip', () => {
     it('leftTopOver autoAdjustOverflow', () => {
         const viewportWidth = 1200;
@@ -26,9 +27,7 @@ describe('tooltip', () => {
         cy.get(dataSelector).scrollIntoView(rightBottomPosition);
         cy.get('[x-placement="rightBottomOver"]').should('have.length', 1);
     });
-});
 
-describe('tooltip', () => {
     it('autoFocusHover', () => {
         cy.visit('http://127.0.0.1:6006/iframe.html?id=tooltip--auto-focus-content-demo&args=&viewMode=story');
         const dataSelector = `[data-cy=hover]`;

+ 1 - 1
package.json

@@ -150,7 +150,7 @@
     "chromatic": "^6.0.6",
     "crypto": "^1.0.1",
     "css-loader": "^3.6.0",
-    "cypress": "^9.2.1",
+    "cypress": "9.5.2",
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.6",
     "enzyme-to-json": "^3.6.2",

+ 131 - 3
packages/semi-foundation/tooltip/foundation.ts

@@ -46,6 +46,7 @@ export interface TooltipAdapter<P = Record<string, any>, S = Record<string, any>
         click: string;
         focus: string;
         blur: string;
+        keydown: string;
     };
     registerTriggerEvent(...args: any[]): void;
     getTriggerBounding(...args: any[]): DOMRect;
@@ -61,6 +62,12 @@ export interface TooltipAdapter<P = Record<string, any>, S = Record<string, any>
     updateContainerPosition(): void;
     updatePlacementAttr(placement: Position): void;
     getContainerPosition(): string;
+    getFocusableElements(node: any): any[];
+    getActiveElement(): any;
+    getContainer(): any;
+    setInitialFocus(): void;
+    notifyEscKeydown(event: any): void;
+    getTriggerNode(): any;
 }
 
 export type Position = ArrayElement<typeof strings.POSITION_SET>;
@@ -154,7 +161,12 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
 
     _generateEvent(types: ArrayElement<typeof strings.TRIGGER_SET>) {
         const eventNames = this._adapter.getEventName();
-        const triggerEventSet = {};
+        const triggerEventSet = {
+            // bind esc keydown on trigger for a11y
+            [eventNames.keydown]: (event) => {
+                this._handleTriggerKeydown(event);
+            },
+        };
         let portalEventSet = {};
         switch (types) {
             case 'focus':
@@ -186,6 +198,13 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
                     this.delayHide();
                     // this.hide('trigger');
                 };
+                // bind focus to hover trigger for a11y
+                triggerEventSet[eventNames.focus] = () => {
+                    this.delayShow();
+                };
+                triggerEventSet[eventNames.blur] = () => {
+                    this.delayHide();
+                };
 
                 portalEventSet = { ...triggerEventSet };
                 if (this.getProp('clickToHide')) {
@@ -205,7 +224,7 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
                 break;
             case 'custom':
                 // when trigger type is 'custom', no need to bind eventHandler
-                // show/hide completely depond on props.visible which change by user
+                // show/hide completely depend on props.visible which change by user
                 break;
             default:
                 break;
@@ -292,7 +311,12 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
     _togglePortalVisible(isVisible: boolean) {
         const nowVisible = this.getState('visible');
         if (nowVisible !== isVisible) {
-            this._adapter.togglePortalVisible(isVisible, () => this._adapter.notifyVisibleChange(isVisible));
+            this._adapter.togglePortalVisible(isVisible, () => {
+                if (isVisible) {
+                    this._adapter.setInitialFocus();
+                }
+                this._adapter.notifyVisibleChange(isVisible);
+            });
         }
     }
 
@@ -797,4 +821,108 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
     _initContainerPosition() {
         this._adapter.updateContainerPosition();
     }
+
+    handleContainerKeydown = (event: any) => {
+        const { guardFocus, closeOnEsc } = this.getProps();
+        switch (event && event.key) {
+            case "Escape":
+                closeOnEsc && this._handleEscKeyDown(event);
+                break;
+            case "Tab":
+                if (guardFocus) {
+                    const container = this._adapter.getContainer();
+                    const focusableElements = this._adapter.getFocusableElements(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);
+                        }
+                    }
+                }
+                break;
+            default:
+                break;
+        }
+    }
+
+    _handleTriggerKeydown(event: any) {
+        const { closeOnEsc } = this.getProps();
+        const container = this._adapter.getContainer();
+        const focusableElements = this._adapter.getFocusableElements(container);
+        const focusableNum = focusableElements.length;
+        
+        switch (event && event.key) {
+            case "Escape":
+                closeOnEsc && this._handleEscKeyDown(event);
+                break;
+            case "ArrowUp":
+                focusableNum && this._handleTriggerArrowUpKeydown(focusableElements, event);
+                break;
+            case "ArrowDown":
+                focusableNum && this._handleTriggerArrowDownKeydown(focusableElements, event);
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * focus trigger 
+     * 
+     * when trigger is 'focus' or 'hover', onFocus is bind to show popup
+     * if we focus trigger, popup will show again
+     * 
+     * 如果 trigger 是 focus 或者 hover,则它绑定了 onFocus,这里我们如果重新 focus 的话,popup 会再次打开
+     * 因此 returnFocusOnClose 只支持 click trigger
+     */
+    _focusTrigger() {
+        const { trigger, returnFocusOnClose } = this.getProps();
+        if (returnFocusOnClose && trigger === 'click') {
+            const triggerNode = this._adapter.getTriggerNode();
+            if (triggerNode && 'focus' in triggerNode) {
+                triggerNode.focus();
+            }
+        }
+    }
+
+    _handleEscKeyDown(event: any) {
+        const { trigger } = this.getProps();
+        if (trigger !== 'custom') {
+            this.hide();
+            this._focusTrigger();
+        }
+        this._adapter.notifyEscKeydown(event);
+    }
+
+    _handleContainerTabKeyDown(focusableElements: any[], event: any) {
+        const activeElement = this._adapter.getActiveElement();
+        const isLastCurrentFocus = focusableElements[focusableElements.length - 1] === activeElement;
+        if (isLastCurrentFocus) {
+            focusableElements[0].focus();
+            event.preventDefault(); // prevent browser default tab move behavior
+        }
+    }
+
+    _handleContainerShiftTabKeyDown(focusableElements: any[], event: any) {
+        const activeElement = this._adapter.getActiveElement();
+        const isFirstCurrentFocus = focusableElements[0] === activeElement;
+        if (isFirstCurrentFocus) {
+            focusableElements[focusableElements.length - 1].focus();
+            event.preventDefault(); // prevent browser default tab move behavior
+        }
+    }
+
+    _handleTriggerArrowDownKeydown(focusableElements: any[], event: any) {
+        focusableElements[0].focus();
+        event.preventDefault(); // prevent browser default scroll behavior
+    }
+
+    _handleTriggerArrowUpKeydown(focusableElements: any[], event: any) {
+        focusableElements[focusableElements.length - 1].focus();
+        event.preventDefault(); // prevent browser default scroll behavior
+    }
 }

+ 8 - 0
packages/semi-foundation/utils/isEscPress.ts

@@ -0,0 +1,8 @@
+import { get } from 'lodash';
+import { ESC_KEY } from './keyCode';
+
+function isEscPress<T extends { key: string }>(e: T) {
+    return get(e, 'key') === ESC_KEY ? true : false;
+}
+
+export default isEscPress;

+ 1 - 0
packages/semi-foundation/utils/keyCode.ts

@@ -428,5 +428,6 @@ const keyCode = {
 
 export const ENTER_KEY = 'Enter';
 export const TAB_KEY = 'Tab';
+export const ESC_KEY = 'Escape';
 
 export default keyCode;

+ 29 - 1
packages/semi-ui/_utils/index.ts

@@ -4,6 +4,7 @@ import React from 'react';
 import { cloneDeepWith, set, get } from 'lodash';
 import warning from '@douyinfe/semi-foundation/utils/warning';
 import { findAll } from '@douyinfe/semi-foundation/utils/getHighlight';
+import { isHTMLElement } from '@douyinfe/semi-foundation/utils/dom';
 /**
  * stop propagation
  *
@@ -161,6 +162,33 @@ export interface HighLightTextHTMLChunk {
  */
 export const isSemiIcon = (icon: any): boolean => React.isValidElement(icon) && get(icon.type, 'elementType') === 'Icon';
 
-export function getActiveElement() {
+export function getActiveElement(): HTMLElement | null {
     return document ? document.activeElement as HTMLElement : null;
+}
+
+export function isNodeContainsFocus(node: HTMLElement) {
+    const activeElement = getActiveElement();
+    return activeElement === node || node.contains(activeElement);
+}
+
+export function 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
+    const focusableElements = Array.from(node.querySelectorAll<HTMLElement>(focusableSelectorsStr));
+    return focusableElements;
 }

+ 75 - 1
packages/semi-ui/popover/_story/popover.stories.js

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
 
 import Popover from '../index';
 import { strings } from '@douyinfe/semi-foundation/tooltip/constants';
-import { Button, Input, Table, IconButton, Modal, Tag } from '@douyinfe/semi-ui';
+import { Button, Input, Table, IconButton, Modal, Tag, Space } from '@douyinfe/semi-ui';
 import SelectInPopover from './SelectInPopover';
 import BtnClose from './BtnClose';
 import PopRight from './PopRight';
@@ -572,3 +572,77 @@ export const ArrowPointAtCenterDemo = () => <ArrowPointAtCenter />;
 ArrowPointAtCenterDemo.story = {
   name: 'arrow point at center'
 }
+
+export const A11yKeyboard = () => {
+  const [visible, setVisible] = React.useState(false);
+  const popStyle = { height: 200, width: 200 };
+
+  const renderContent = ({ initialFocusRef }) => {
+    return (
+      <div style={popStyle} data-cy="pop">
+        <button data-cy="pop-focusable-first">first focusable</button>
+        <a href="https://semi.design">link</a>
+        {/* <input ref={initialFocusRef} placeholder="init focus" /> */}
+        <input placeholder="" defaultValue="semi" />
+        <a href="https://semi.design">link2</a>
+        <button data-cy="pop-focusable-last">last focusable</button>
+      </div>
+    );
+  };
+
+  const noFocusableContent = (
+    <div style={popStyle}>没有可聚焦元素</div>
+  );
+
+  const initFocusContent = ({ initialFocusRef }) => {
+    return (
+      <div style={popStyle} data-cy="pop">
+        <button data-cy="pop-focusable-first">first focusable</button>
+        <input placeholder="" defaultValue="semi" ref={initialFocusRef} data-cy="initial-focus-input" />
+        <button data-cy="pop-focusable-last">last focusable</button>
+      </div>
+    );
+  };
+
+  return (
+      <div style={{ paddingLeft: 100, paddingTop: 100 }}>
+          <Space spacing={100}>
+              <Popover content={renderContent} trigger="click" motion={false}>
+                  <Button data-cy="click">click</Button>
+              </Popover>
+              <Popover content={renderContent} trigger="hover">
+                  <span data-cy="hover">hover</span>
+              </Popover>
+              <Popover content={renderContent} trigger="focus">
+                  <Input data-cy="focus" defaultValue="focus" style={{ width: 150 }} />
+              </Popover>
+              <Popover
+                  content={renderContent}
+                  trigger="custom"
+                  visible={visible}
+                  onEscKeyDown={() => {
+                      console.log('esc key down');
+                      setVisible(false);
+                  }}
+              >
+                  <Button onClick={() => setVisible(!visible)} data-cy="custom">
+                    custom trigger + click me toggle show
+                  </Button>
+              </Popover>
+              <Popover content={noFocusableContent} trigger="click" data-cy="click-pop-contains-no-focusable">
+                  <Button>pop内没有可聚焦元素</Button>
+              </Popover>
+              <Popover content={initFocusContent} trigger="click" motion={false}>
+                  <Button data-cy="initial-focus">custom initialFocus</Button>
+              </Popover>
+              <Popover content={renderContent} trigger="click" motion={false} closeOnEsc={false}>
+                  <Button data-cy="closeOnEsc-false">closeOnEsc=false</Button>
+              </Popover>
+              <Popover content={renderContent} trigger="click" motion={false} returnFocusOnClose={false}>
+                  <Button data-cy="returnFocusOnClose-false">returnFocusOnClose=false</Button>
+              </Popover>
+          </Space>
+      </div>
+  );
+};
+A11yKeyboard.storyName = "a11y keyboard and focus";

+ 24 - 8
packages/semi-ui/popover/index.tsx

@@ -3,12 +3,12 @@ import classNames from 'classnames';
 import PropTypes from 'prop-types';
 import ConfigContext from '../configProvider/context';
 import { cssClasses, strings, numbers } from '@douyinfe/semi-foundation/popover/constants';
-import Tooltip, { ArrowBounding, Position, Trigger } from '../tooltip/index';
+import Tooltip, { ArrowBounding, Position, TooltipProps, Trigger, RenderContentProps } from '../tooltip/index';
 import Arrow from './Arrow';
 import '@douyinfe/semi-foundation/popover/popover.scss';
 import { BaseProps } from '../_base/baseComponent';
 import { Motion } from '../_base/base';
-import { noop } from 'lodash';
+import { isFunction, noop } from 'lodash';
 
 export { ArrowProps } from './Arrow';
 declare interface ArrowStyle {
@@ -19,7 +19,7 @@ declare interface ArrowStyle {
 
 export interface PopoverProps extends BaseProps {
     children?: React.ReactNode;
-    content?: React.ReactNode;
+    content?: TooltipProps['content'];
     visible?: boolean;
     autoAdjustOverflow?: boolean;
     motion?: Motion;
@@ -40,6 +40,10 @@ export interface PopoverProps extends BaseProps {
     rePosKey?: string | number;
     getPopupContainer?: () => HTMLElement;
     zIndex?: number;
+    closeOnEsc?: TooltipProps['closeOnEsc'];
+    guardFocus?: TooltipProps['guardFocus'];
+    returnFocusOnClose?: TooltipProps['returnFocusOnClose'];
+    onEscKeyDown?: TooltipProps['onEscKeyDown'];
 }
 
 export interface PopoverState {
@@ -52,7 +56,7 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
     static contextType = ConfigContext;
     static propTypes = {
         children: PropTypes.node,
-        content: PropTypes.node,
+        content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
         visible: PropTypes.bool,
         autoAdjustOverflow: PropTypes.bool,
         motion: PropTypes.oneOfType([PropTypes.bool, PropTypes.object, PropTypes.func]),
@@ -76,6 +80,7 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
         arrowPointAtCenter: PropTypes.bool,
         arrowBounding: PropTypes.object,
         prefixCls: PropTypes.string,
+        guardFocus: PropTypes.bool,
     };
 
     static defaultProps = {
@@ -90,9 +95,13 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
         position: 'bottom',
         prefixCls: cssClasses.PREFIX,
         onClickOutSide: noop,
+        onEscKeyDown: noop,
+        closeOnEsc: true,
+        returnFocusOnClose: true,
+        guardFocus: true,
     };
 
-    renderPopCard() {
+    renderPopCard = ({ initialFocusRef }: { initialFocusRef: RenderContentProps['initialFocusRef'] }) => {
         const { content, contentClassName, prefixCls } = this.props;
         const { direction } = this.context;
         const popCardCls = classNames(
@@ -102,13 +111,20 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
                 [`${prefixCls}-rtl`]: direction === 'rtl',
             }
         );
+        const contentNode = this.renderContentNode({ initialFocusRef, content });
         return (
             <div className={popCardCls}>
-                <div className={`${prefixCls}-content`}>{content}</div>
+                <div className={`${prefixCls}-content`}>{contentNode}</div>
             </div>
         );
     }
 
+    renderContentNode = (props: { content: TooltipProps['content'], initialFocusRef: RenderContentProps['initialFocusRef'] }) => {
+        const { initialFocusRef, content } = props;
+        const contentProps = { initialFocusRef };
+        return !isFunction(content) ? content : content(contentProps);
+    };
+
     render() {
         const {
             children,
@@ -122,7 +138,6 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
             ...attr
         } = this.props;
         let { spacing } = this.props;
-        const popContent = this.renderPopCard();
 
         const arrowProps = {
             position,
@@ -141,11 +156,12 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
 
         return (
             <Tooltip
+                guardFocus
                 {...(attr as any)}
                 trigger={trigger}
                 position={position}
                 style={style}
-                content={popContent}
+                content={this.renderPopCard}
                 prefixCls={prefixCls}
                 spacing={spacing}
                 showArrow={arrow}

+ 71 - 21
packages/semi-ui/tooltip/index.tsx

@@ -3,7 +3,7 @@ import React, { isValidElement, cloneElement } from 'react';
 import ReactDOM from 'react-dom';
 import classNames from 'classnames';
 import PropTypes from 'prop-types';
-import { throttle, noop, get, omit, each, isEmpty } from 'lodash';
+import { throttle, noop, get, omit, each, isEmpty, isFunction } from 'lodash';
 
 import { BASE_CLASS_PREFIX } from '@douyinfe/semi-foundation/base/constants';
 import warning from '@douyinfe/semi-foundation/utils/warning';
@@ -17,7 +17,7 @@ import '@douyinfe/semi-foundation/tooltip/tooltip.scss';
 
 import BaseComponent, { BaseProps } from '../_base/baseComponent';
 import { isHTMLElement } from '../_base/reactUtils';
-import { stopPropagation } from '../_utils';
+import { getActiveElement, getFocusableElements, stopPropagation } from '../_utils';
 import Portal from '../_portal/index';
 import ConfigContext from '../configProvider/context';
 import TriangleArrow from './TriangleArrow';
@@ -36,6 +36,12 @@ export interface ArrowBounding {
     height?: number;
 }
 
+export interface RenderContentProps {
+    initialFocusRef?: React.RefObject<HTMLElement>;
+}
+
+export type RenderContent = (props: RenderContentProps) => React.ReactNode;
+
 export interface TooltipProps extends BaseProps {
     children?: React.ReactNode;
     motion?: Motion;
@@ -49,7 +55,7 @@ export interface TooltipProps extends BaseProps {
     clickToHide?: boolean;
     visible?: boolean;
     style?: React.CSSProperties;
-    content?: React.ReactNode;
+    content?: React.ReactNode | RenderContent;
     prefixCls?: string;
     onVisibleChange?: (visible: boolean) => void;
     onClickOutSide?: (e: React.MouseEvent) => void;
@@ -65,6 +71,10 @@ export interface TooltipProps extends BaseProps {
     stopPropagation?: boolean;
     clickTriggerToHide?: boolean;
     wrapperClassName?: string;
+    closeOnEsc?: boolean;
+    guardFocus?: boolean;
+    returnFocusOnClose?: boolean;
+    onEscKeyDown?: (e: React.KeyboardEvent) => void;
 }
 interface TooltipState {
     visible: boolean;
@@ -108,7 +118,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         clickTriggerToHide: PropTypes.bool,
         visible: PropTypes.bool,
         style: PropTypes.object,
-        content: PropTypes.node,
+        content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
         prefixCls: PropTypes.string,
         onVisibleChange: PropTypes.func,
         onClickOutSide: PropTypes.func,
@@ -123,6 +133,8 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         // private
         role: PropTypes.string,
         wrapWhenSpecial: PropTypes.bool, // when trigger has special status such as "disabled" or "loading", wrap span
+        guardFocus: PropTypes.bool,
+        returnFocusOnClose: PropTypes.bool,
     };
 
     static defaultProps = {
@@ -143,11 +155,16 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         showArrow: true,
         wrapWhenSpecial: true,
         zIndex: numbers.DEFAULT_Z_INDEX,
+        closeOnEsc: false,
+        guardFocus: false,
+        returnFocusOnClose: false,
+        onEscKeyDown: noop,
     };
 
     eventManager: Event;
     triggerEl: React.RefObject<unknown>;
-    containerEl: React.RefObject<unknown>;
+    containerEl: React.RefObject<HTMLDivElement>;
+    initialFocusRef: React.RefObject<HTMLElement>;
     clickOutsideHandler: any;
     resizeHandler: any;
     isWrapped: boolean;
@@ -155,6 +172,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
     scrollHandler: any;
     getPopupContainer: () => HTMLElement;
     containerPosition: string;
+    foundation: TooltipFoundation;
 
     constructor(props: TooltipProps) {
         super(props);
@@ -180,6 +198,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         this.eventManager = new Event();
         this.triggerEl = React.createRef();
         this.containerEl = React.createRef();
+        this.initialFocusRef = React.createRef();
         this.clickOutsideHandler = null;
         this.resizeHandler = null;
         this.isWrapped = false; // Identifies whether a span element is wrapped
@@ -197,7 +216,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
             // eslint-disable-next-line @typescript-eslint/ban-ts-comment
             // @ts-ignore
             off: (...args: any[]) => this.eventManager.off(...args),
-            insertPortal: (content: string, { position, ...containerStyle }: { position: Position }) => {
+            insertPortal: (content: TooltipProps['content'], { position, ...containerStyle }: { position: Position }) => {
                 this.setState(
                     {
                         isInsert: true,
@@ -223,6 +242,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                 click: 'onClick',
                 focus: 'onFocus',
                 blur: 'onBlur',
+                keydown: 'onKeyDown'
             }),
             registerTriggerEvent: (triggerEventSet: Record<string, any>) => {
                 this.setState({ triggerEventSet });
@@ -236,12 +256,8 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                 // eslint-disable-next-line
                 // It may be a React component or an html element
                 // There is no guarantee that triggerE l.current can get the real dom, so call findDOMNode to ensure that you can get the real dom
-                let triggerDOM = this.triggerEl.current;
-                if (!isHTMLElement(this.triggerEl.current)) {
-                    const realDomNode = ReactDOM.findDOMNode(this.triggerEl.current as React.ReactInstance);
-                    (this.triggerEl as any).current = realDomNode;
-                    triggerDOM = realDomNode;
-                }
+                const triggerDOM = this.adapter.getTriggerNode();
+                (this.triggerEl as any).current = triggerDOM;
                 return triggerDOM && (triggerDOM as Element).getBoundingClientRect();
             },
             // Gets the outer size of the specified container
@@ -317,7 +333,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     let el = this.triggerEl && this.triggerEl.current;
                     let popupEl = this.containerEl && this.containerEl.current;
                     el = ReactDOM.findDOMNode(el as React.ReactInstance);
-                    popupEl = ReactDOM.findDOMNode(popupEl as React.ReactInstance);
+                    popupEl = ReactDOM.findDOMNode(popupEl as React.ReactInstance) as HTMLDivElement;
                     if (
                         (el && !(el as any).contains(e.target) && popupEl && !(popupEl as any).contains(e.target)) ||
                         this.props.clickTriggerToHide
@@ -363,10 +379,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     if (!this.mounted) {
                         return false;
                     }
-                    let triggerDOM = this.triggerEl.current;
-                    if (!isHTMLElement(this.triggerEl.current)) {
-                        triggerDOM = ReactDOM.findDOMNode(this.triggerEl.current as React.ReactInstance);
-                    }
+                    const triggerDOM = this.adapter.getTriggerNode();
                     const isRelativeScroll = e.target.contains(triggerDOM);
                     if (isRelativeScroll) {
                         const scrollPos = { x: e.target.scrollLeft, y: e.target.scrollTop };
@@ -392,6 +405,29 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                 }
             },
             getContainerPosition: () => this.containerPosition,
+            getContainer: () => this.containerEl && this.containerEl.current,
+            getTriggerNode: () => {
+                let triggerDOM = this.triggerEl.current;
+                if (!isHTMLElement(this.triggerEl.current)) {
+                    triggerDOM = ReactDOM.findDOMNode(this.triggerEl.current as React.ReactInstance);
+                }
+                return triggerDOM as Element;
+            },
+            getFocusableElements: (node: HTMLDivElement) => {
+                return getFocusableElements(node);
+            },
+            getActiveElement: () => {
+                return getActiveElement();
+            },
+            setInitialFocus: () => {
+                const focusRefNode = get(this, 'initialFocusRef.current');
+                if (focusRefNode && 'focus' in focusRefNode) {
+                    focusRefNode.focus();
+                } 
+            },
+            notifyEscKeydown: (event: React.KeyboardEvent) => {
+                this.props.onEscKeyDown(event);
+            }
         };
     }
 
@@ -491,9 +527,21 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         }
     };
 
+    handlePortalInnerKeyDown = (e: React.KeyboardEvent) => {
+        this.foundation.handleContainerKeydown(e);
+    }
+
+    renderContentNode = (content: TooltipProps['content']) => {
+        const contentProps = {
+            initialFocusRef: this.initialFocusRef
+        };
+        return !isFunction(content) ? content : content(contentProps);
+    };
+
     renderPortal = () => {
         const { containerStyle = {}, visible, portalEventSet, placement, transitionState, id, isPositionUpdated } = this.state;
         const { prefixCls, content, showArrow, style, motion, role, zIndex } = this.props;
+        const contentNode = this.renderContentNode(content);
         const { className: propClassName } = this.props;
         const direction = this.context.direction;
         const className = classNames(propClassName, {
@@ -524,7 +572,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                                 x-placement={placement}
                                 id={id}
                             >
-                                {content}
+                                {contentNode}
                                 {icon}
                             </div>
                         ) :
@@ -533,7 +581,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
             </TooltipTransition>
         ) : (
             <div className={className} {...portalEventSet} x-placement={placement} style={{ visibility: motion ? undefined : 'visible', ...style }}>
-                {content}
+                {contentNode}
                 {icon}
             </div>
         );
@@ -546,6 +594,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     style={portalInnerStyle}
                     ref={this.setContainerEl}
                     onClick={this.handlePortalInnerClick}
+                    onKeyDown={this.handlePortalInnerKeyDown}
                 >
                     {inner}
                 </div>
@@ -587,7 +636,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
 
     render() {
         const { isInsert, triggerEventSet, visible, id } = this.state;
-        const { wrapWhenSpecial, role } = this.props;
+        const { wrapWhenSpecial, role, trigger } = this.props;
         let { children } = this.props;
         const childrenStyle = { ...get(children, 'props.style') };
         const extraStyle: React.CSSProperties = {};
@@ -648,6 +697,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     ref.current = node;
                 }
             },
+            tabIndex: trigger === 'hover' ? 0 : undefined, // a11y keyboard
         });
 
         // If you do not add a layer of div, in order to bind the events and className in the tooltip, you need to cloneElement children, but this time it may overwrite the children's original ref reference
@@ -661,4 +711,4 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
     }
 }
 
-export { Position };
+export { Position };

File diff suppressed because it is too large
+ 190 - 114
yarn.lock


Some files were not shown because too many files changed in this diff