Przeglądaj źródła

feat(a11y): popconfirm keyboard and focus #205

走鹃 3 lat temu
rodzic
commit
f7a6fc9a03

+ 65 - 1
content/feedback/popconfirm/index-en-US.md

@@ -116,6 +116,51 @@ function TypesConfirmDemo(props = {}) {
 }
 ```
 
+### Initialize the Focus Position of Popup Layer
+
+`okButtonProps` and `cancelButtonProps` support passing in the `initialFocus` parameter, which will automatically focus at this position when the panel is opened. Version 2.10.0 supported.
+
+`content` supports function, and its parameter is an object, which binds `initialFocusRef` to the focusable DOM or component, and it will automatically focus at this position when the panel is opened. Version 2.10.0 supported.
+
+```jsx live=true
+import React from 'react';
+import { Button, Popconfirm, Space } from '@douyinfe/semi-ui';
+
+() => {
+    return (
+        <Space>
+            <Popconfirm
+                title="Are you sure you want to save this edit?"
+                content="This modification will be irreversible"
+                okButtonProps={{
+                    initialFocus: true,
+                    type: 'danger',
+                }}
+            >
+                <Button>Confirm focus</Button>
+            </Popconfirm>
+            <Popconfirm
+                title="Are you sure you want to save this edit?"
+                content="This modification will be irreversible"
+                cancelButtonProps={{
+                    initialFocus: true,
+                }}
+            >
+                <Button>Cancel focus</Button>
+            </Popconfirm>
+            <Popconfirm
+                title="Are you sure you want to save this edit?"
+                content={({ initialFocusRef }) => {
+                    return <input ref={initialFocusRef} placeholder="focus here" />;
+                }}
+            >
+                <Button>Content focus</Button>
+            </Popconfirm>
+        </Space>
+    );
+};
+```
+
 ### Use with Tooltip or Popover
 
 Please refer to [Use with Tooltip/Popover](/en-US/show/tooltip#Use-with-Popver-or-Popconfirm)
@@ -128,10 +173,12 @@ Please refer to [Use with Tooltip/Popover](/en-US/show/tooltip#Use-with-Popver-o
 | cancelText         | Cancel button text                                                                                                                                                    | string                     | "Cancel"            |
 | cancelButtonProps  | Properties for cancel button                                                                                                                                          | object                     |                     | **0.29.0**        |
 | cancelType         | Cancel button type                                                                                                                                                    | string                     | "tertiary"          |
-| content            | Content displayed                                                                                                                                                     | string \| ReactNode        |                     |
+| 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 (function type, supported in version 2.10.0)                                                                                                         | ReactNode\|({ initialFocusRef }) => ReactNode        |                     |
 | defaultVisible     | Bubble box is displayed by default                                                                                                                                    | boolean                    |                     | **0.19.0**        |
 | disabled           | Click on the Pop confirmation box to see if the bubbles pop up.                                                                                                       | boolean                    | false               |
 | getPopupContainer  | Specify the parent DOM, and the pop-up layer will be rendered into the DOM. Customization needs to set `position: relative`                                                                                                       | Function():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** |
 | icon               | Custom pop bubble Icon icon                                                                                                                                           |  ReactNode        | <IconAlertTriangle size="extra-large" />    |
 | motion             | Whether there is animation when the drop-down list appears/hidden. You can customize animation by passing in an object that conforms to the structure | boolean\|object | true |
 | position           | Directions, optional values: `top`, `topLeft`, `topRight`, `leftTop`, `leftBottom`, `rightTop`, `rightTop`, `rightBottom`, `bottomLeft`, `bottomRight`, `bottomRight` | string                     | "bottomLeft"        |
@@ -141,6 +188,7 @@ Please refer to [Use with Tooltip/Popover](/en-US/show/tooltip#Use-with-Popver-o
 | showArrow          | Whether to show arrow triangle                                                                                                                         | boolean                          | false               |                   |
 | stopPropagation    | Whether to prevent the click event on the bomb layer from bubbling                                                                                                                | boolean                          | true                | **0.34.0** |
 | position           | Popup layer position,Optional value:`top`,`topLeft`,`topRight`,`left`,`leftTop`,`leftBottom`,<br/>`right`,`rightTop`,`rightBottom`,`bottom`,`bottomLeft`,`bottomRight` | string                           | "bottomLeft"        |
+| 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** |
 | title              | Displayed title                                                                                                                                  | string\|ReactNode                |                     |
 | trigger            | Timing to trigger the display, optional value:hover / focus / click / custom                                                                                         | string                |   'click'                  |
 | visible            | Whether the bubble box displays controlled attributes                                                                                                                   | boolean                          |                     | **0.19.0**        |
@@ -148,6 +196,22 @@ Please refer to [Use with Tooltip/Popover](/en-US/show/tooltip#Use-with-Popver-o
 | onConfirm          | Click the confirmation button to call back.                                                                                                                           | (e) => void                |                     |
 | onCancel           | Click the Cancel button to call back.                                                                                                                                 | (e) => void                |                     |
 | onVisibleChange    | Bubble box toggle shows hidden callbacks                                                                                                                              | (visible: boolean) => void | () => {}            | **0.19.0**        |
+| onEscKeyDown | Called when Esc key is pressed in trigger or popup layer | function(e:event) | | **2.8.0** |
 | onClickOutSide     | Callback when the pop-up layer is in the display state and the non-Children, non-floating layer inner area is clicked                                                 | (e: event) => void         |                     | **2.1.0**        |
+
+## Accessibility
+
+### ARIA
+
+For ARIA, please refer to [Popover](https://semi.design/zh-CN/show/popover#ARIA)
+
+### Keyboard and focus
+
+- Popconfirm must have trigger, trigger can be focused, use `Enter` key to open Popconfirm
+- After Popconfirm is activated, press the arrow key ⬇️ to move the focus to Popconfirm. The initial focus of Popconfirm should follow several principles:
+    - If the Popconfirm contains the last step of an irreversible process, such as: deleting data, etc., then this initial focus is preferably on the least destructive interactable element, such as: the cancel button (by passing the `initialFocus` to the object `cancelButtonProps`)
+    - If you only read text in Popconfirm, it is recommended to set the initial focus on the most likely interactive elements, such as: confirm button (implemented by passing `initialFocus` to the object `okButtonProps` )
+- Keyboard users can dismiss Popconfirm by pressing `Esc` and focus should return to the trigger. After the user closes the Pop through the interactive element within the Popconfirm, the focus should also return to the trigger
+
 ## Design Tokens
 <DesignToken/>

+ 96 - 36
content/feedback/popconfirm/index.md

@@ -2,12 +2,11 @@
 localeCode: zh-CN
 order: 64
 category: 反馈类
-title:  Popconfirm 气泡确认框
+title: Popconfirm 气泡确认框
 icon: doc-popconfirm
 brief: 点击元素,弹出气泡式的确认框。
 ---
 
-
 ## 何时使用
 
 目标元素的操作需要用户进一步的确认时,在目标元素附近弹出浮层提示,询问用户。
@@ -37,12 +36,7 @@ import { Popconfirm, Button, Toast } from '@douyinfe/semi-ui';
         Toast.warning('取消保存!');
     };
     return (
-        <Popconfirm
-            title="确定是否要保存此修改?"
-            content="此修改将不可逆"
-            onConfirm={onConfirm}
-            onCancel={onCancel}
-        >
+        <Popconfirm title="确定是否要保存此修改?" content="此修改将不可逆" onConfirm={onConfirm} onCancel={onCancel}>
             <Button>保存</Button>
         </Popconfirm>
     );
@@ -94,7 +88,9 @@ function TypesConfirmDemo(props = {}) {
             <RadioGroup onChange={changeType} value={type} style={{ marginTop: 14, marginBottom: 14 }}>
                 {keys.map(key => (
                     <Radio key={key} value={key}>
-                        <strong style={{ color: `var(--semi-color-${key === 'default' ? 'primary' : key})` }}>{key}</strong>
+                        <strong style={{ color: `var(--semi-color-${key === 'default' ? 'primary' : key})` }}>
+                            {key}
+                        </strong>
                     </Radio>
                 ))}
             </RadioGroup>
@@ -115,38 +111,102 @@ function TypesConfirmDemo(props = {}) {
 }
 ```
 
+### 初始化弹出层焦点位置
+
+okButtonProps 和 cancelButtonProps 支持传入 `initialFocus` 参数,传入后打开面板时会自动聚焦在该位置。2.10.0 版本支持。
+
+content 支持传入函数,它的入参是一个对象,将 `initialFocusRef` 绑定在可聚焦 DOM 或组件上,打开面板时会自动聚焦在该位置。2.10.0 版本支持。
+
+```jsx live=true
+import React from 'react';
+import { Button, Popconfirm, Space } from '@douyinfe/semi-ui';
+
+() => {
+    return (
+        <Space>
+            <Popconfirm
+                title="确定是否要保存此修改?"
+                content="此修改将不可逆"
+                okButtonProps={{
+                    initialFocus: true,
+                    type: 'danger',
+                }}
+            >
+                <Button>确认聚焦</Button>
+            </Popconfirm>
+            <Popconfirm
+                title="确定是否要保存此修改?"
+                content="此修改将不可逆"
+                cancelButtonProps={{
+                    initialFocus: true,
+                }}
+            >
+                <Button>取消聚焦</Button>
+            </Popconfirm>
+            <Popconfirm
+                title="确定是否要保存此修改?"
+                content={({ initialFocusRef }) => {
+                    return <input ref={initialFocusRef} placeholder="focus here" />;
+                }}
+            >
+                <Button>内容聚焦</Button>
+            </Popconfirm>
+        </Space>
+    );
+};
+```
+
 ### 搭配 Tooltip 或 Popover 使用
 
 请参考[搭配使用](/zh-CN/show/tooltip#%E6%90%AD%E9%85%8D-popover-%E6%88%96-popconfirm-%E4%BD%BF%E7%94%A8)
 
 ## API 参考
 
-| 属性               | 说明                                                                                                                                        | 类型                             | 默认值              | 版本              |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------- | ----------------- |
-| arrowPointAtCenter | “小三角”是否指向元素中心,需要同时传入"showArrow=true"                                                                                      | boolean                          | false               | **0.34.0** |
-| cancelText         | 取消按钮文字                                                                                                                                | string                           | "取消"              |
-| cancelButtonProps  | 取消按钮的 props                                                                                                                            | object                           |                     | **0.29.0**        |
-| cancelType         | 取消按钮类型                                                                                                                                | string                           | "tertiary"          |
-| content            | 显示的内容                                                                                                                                  | string\|ReactNode                |                     |
-| defaultVisible     | 气泡框默认是否展示                                                                                                                          | boolean                          |                     | **0.19.0**        |
-| disabled           | 点击 Popconfirm 子元素是否弹出气泡确认框                                                                                                    | boolean                          | false               |
-| getPopupContainer  | 指定父级 DOM,弹层将会渲染至该 DOM 中,自定义时容器需要设置 `position: relative`                                                                                                       | Function():HTMLElement           | () => document.body |
-| icon               | 自定义弹出气泡 Icon 图标                                                                                                                    | ReactNode                | <IconAlertTriangle size="extra-large" />    |
-| motion             | 下拉列表出现/隐藏时,是否有动画 | boolean\|object | true |
-| okText             | 确认按钮文字                                                                                                                                | string                           | "确认"              |
-| okType             | 确认按钮类型                                                                                                                                | string                           | "primary"           |
-| okButtonProps      | 确认按钮的 props                                                                                                                            | object                           |                     | **0.29.0**        |
-| position           | 方向,可选值:`top`,`topLeft`,`topRight`,`left`,`leftTop`,`leftBottom`,<br/>`right`,`rightTop`,`rightBottom`,`bottom`,`bottomLeft`,`bottomRight` | string                           | "bottomLeft"        |
-| showArrow          | 是否显示箭头三角形                                                                                                                          | boolean                          | false               |                   |
-| stopPropagation    | 是否阻止弹层上的点击事件冒泡                                                                                                                | boolean                          | true                | **0.34.0** |
-| title              | 显示的标题                                                                                                                                  | string\|ReactNode                |                     |
-| trigger            | 触发展示的时机,可选值:hover / focus / click / custom                                                                                         | string                |   'click'                  |
-| visible            | 气泡框是否展示的受控属性                                                                                                                    | boolean                          |                     | **0.19.0**        |
-| zIndex             | 浮层 z-index 值                                                                                                                             | number                           | 1030                |
-| onConfirm          | 点击确认按钮回调                                                                                                                            | Function(e)                      |                     |
-| onCancel           | 点击取消按钮回调                                                                                                                            | Function(e)                      |                     |
-| onClickOutSide     | 当弹出层处于展示状态,点击非Children、非浮层内部区域时的回调                                                                                      | Function(e)                      |  **2.1.0**      |
-| onVisibleChange    | 气泡框切换显示隐藏的回调                                                                                                               | Function(visible: boolean): void | () => {}            | **0.19.0**        |
+| 属性 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| arrowPointAtCenter | “小三角”是否指向元素中心,需要同时传入"showArrow=true" | boolean | false | **0.34.0** |
+| cancelText | 取消按钮文字 | string | "取消" |
+| cancelButtonProps | 取消按钮的 props | object |  | **0.29.0** |
+| cancelType | 取消按钮类型 | string | "tertiary" |
+| closeOnEsc | 在 trigger 聚焦时或在弹出层内聚焦元素上按 Esc 键是否关闭面板,受控时不生效 | boolean | true | **2.8.0** |
+| content | 显示的内容(函数类型,2.10.0 版本支持) | ReactNode\|({ initialFocusRef }) => ReactNode |  |
+| defaultVisible | 气泡框默认是否展示 | boolean |  | **0.19.0** |
+| disabled | 点击 Popconfirm 子元素是否弹出气泡确认框 | boolean | false |
+| getPopupContainer | 指定父级 DOM,弹层将会渲染至该 DOM 中,自定义时容器需要设置 `position: relative` | Function():HTMLElement | () => document.body |
+| guardFocus | 当焦点处于弹出层内时,切换 Tab 是否让焦点在弹出层内循环 | boolean | true | **2.8.0** |
+| icon | 自定义弹出气泡 Icon 图标 | ReactNode | <IconAlertTriangle size="extra-large" /> |
+| motion | 下拉列表出现/隐藏时,是否有动画 | boolean\|object | true |
+| okText | 确认按钮文字 | string | "确认" |
+| okType | 确认按钮类型 | string | "primary" |
+| okButtonProps | 确认按钮的 props | object |  | **0.29.0** |
+| position | 方向,可选值:`top`,`topLeft`,`topRight`,`left`,`leftTop`,`leftBottom`,<br/>`right`,`rightTop`,`rightBottom`,`bottom`,`bottomLeft`,`bottomRight` | string | "bottomLeft" |
+| returnFocusOnClose | 按下 Esc 键后,焦点是否回到 trigger 上,只有设置 trigger 为 click 时生效 | boolean | true | **2.8.0** |
+| showArrow | 是否显示箭头三角形 | boolean | false |  |
+| stopPropagation | 是否阻止弹层上的点击事件冒泡 | boolean | true | **0.34.0** |
+| title | 显示的标题 | string\|ReactNode |  |
+| trigger | 触发展示的时机,可选值:hover / focus / click / custom | string | 'click' |
+| visible | 气泡框是否展示的受控属性 | boolean |  | **0.19.0** |
+| zIndex | 浮层 z-index 值 | number | 1030 |
+| onConfirm | 点击确认按钮回调 | Function(e) |  |
+| onCancel | 点击取消按钮回调 | Function(e) |  |
+| onClickOutSide | 当弹出层处于展示状态,点击非 Children、非浮层内部区域时的回调 | Function(e) | **2.1.0** |
+| onEscKeyDown | 在 trigger 或弹出层按 Esc 键时调用 | function(e:event) |  | **2.8.0** |
+| onVisibleChange | 气泡框切换显示隐藏的回调 | Function(visible: boolean): void | () => {} | **0.19.0** |
+
+## Accessibility
+
+### ARIA
+
+语义化请参考 [Popover](https://semi.design/zh-CN/show/popover#ARIA)
+
+### 键盘和焦点
+
+-   Popconfirm 必须带有触发器,触发器可被聚焦,使用 Enter 键打开 Popconfirm
+-   Popconfirm 激活后,按下方向键 ⬇️ 将焦点移动到 Popconfirm 上。Popconfirm 的初始焦点应当遵循以下几个原则:
+    -   如果 Popconfirm 内包含一个不可逆转过程的最后一个步骤,比如:删除数据等,那么这个初始焦点最好放在破坏性最小的可交互元素上,如:关闭按钮 ( 通过向对象 cancelButtonProps 中传入 initialFocus 实现 )
+    -   如果 Popconfirm 内仅为阅读文本,那么建议将初始焦点设置在最可能常用的交互元素上,如:确定按钮 ( 通过向对象 okButtonProps 中传入 initialFocus 实现 )
+-   键盘用户能够通过按 Esc 关闭 Popconfirm,并且焦点应该返回到触发器上。用户通过 Popconfirm 内的交互元素关闭该 Pop 后,焦点也应当返回到触发器上
 
 ## 设计变量
-<DesignToken/>
+
+<DesignToken/>

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

@@ -487,7 +487,7 @@ 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 |  |
+| content | Content displayed (function type, supported in version 2.8.0) | ReactNode \| ({ initialFocusRef }) => 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** |

+ 3 - 3
cypress/integration/notification.spec.js

@@ -9,8 +9,8 @@ describe('notification', () => {
         cy.get('.semi-button').click();
         cy.get('[data-cy=notice-container] .semi-notification-notice').should("have.length", 5);
         // addNotice 返回 id 是固定的,等待代码修改
-        // cy.wait(1000);
-        // cy.get('[data-cy=notice-container] .semi-notification-notice .semi-notification-notice-icon-close').first().click();
-        // cy.get('[data-cy=notice-container] .semi-notification-notice').should("have.length", 4);
+        cy.wait(1000);
+        cy.get('[data-cy=notice-container] .semi-notification-notice .semi-notification-notice-icon-close').first().click();
+        cy.get('[data-cy=notice-container] .semi-notification-notice').should("have.length", 4);
     });
 });

+ 37 - 0
cypress/integration/popconfirm.spec.js

@@ -0,0 +1,37 @@
+// popConfirm.spec.js created with Cypress
+//
+// 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
+
+// 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('popConfirm', () => {
+    it('confirm focus', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popconfirm--keyboard-and-focus&args=&viewMode=story');
+        cy.get('[data-cy=initial-focus-confirm]').click();
+        cy.get('.semi-popconfirm-footer .semi-button').eq(1).should('be.focused');
+        cy.get('.semi-popconfirm-footer .semi-button').eq(1).click();
+        cy.get('[data-cy=initial-focus-confirm] .semi-button').should('be.focused');
+    });
+
+    it('cancel focus', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popconfirm--keyboard-and-focus&args=&viewMode=story');
+        cy.get('[data-cy=initial-focus-cancel]').click();
+        cy.get('.semi-popconfirm-footer .semi-button').eq(0).should('be.focused');
+        cy.get('.semi-popconfirm-footer .semi-button').eq(0).click();
+        cy.get('[data-cy=initial-focus-cancel] .semi-button').should('be.focused');
+    });
+
+    it('content focus', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=popconfirm--keyboard-and-focus&args=&viewMode=story');
+        cy.get('[data-cy=initial-focus-content]').click();
+        cy.get('.semi-popconfirm-header-body input').eq(0).should('be.focused');
+        cy.get('.semi-popconfirm-header .semi-popconfirm-btn-close').eq(0).click();
+        cy.get('[data-cy=initial-focus-content] .semi-button').should('be.focused');
+    });
+});

+ 27 - 0
packages/semi-foundation/button/button.scss

@@ -241,4 +241,31 @@ $module: #{$prefix}-button;
     }
 }
 
+// use this mixin when override button focus style in semi component
+@mixin button-focus-style {
+    .#{$prefix}-button-initial-focus {
+        &.#{$prefix}-button-primary,
+        &.#{$prefix}-button-secondary,
+        &.#{$prefix}-button-tertiary,
+        &.#{$prefix}-button-warning,
+        &.#{$prefix}-button-danger {
+            &:focus {
+                outline: $width-button-outline solid $color-button_primary-outline-focus;
+            }
+        }
+
+        &.#{$prefix}-button-danger {
+            &:not(.#{$module}-borderless):not(.#{$module}-light):focus {
+                outline: $width-button-outline solid $color-button_danger-outline-focus;
+            }
+        }
+
+        &.#{$prefix}-button-warning {
+            &:not(.#{$module}-borderless):not(.#{$module}-light):focus {
+                outline: $width-button-outline solid $color-button_warning-outline-focus;
+            }
+        }
+    }
+}
+
 @import './rtl.scss';

+ 4 - 0
packages/semi-foundation/popconfirm/popconfirm.scss

@@ -1,5 +1,6 @@
 //@import '../theme/variables.scss';
 @import './variables.scss';
+@import '../button/button.scss';
 
 $module: #{$prefix}-popconfirm;
 
@@ -82,6 +83,9 @@ $module: #{$prefix}-popconfirm;
         & > .#{$prefix}-button:first-child:not(:last-child) {
             margin-right: $spacing-popconfirm_footer_btn-marginRight;
         }
+
+        // `focus-visible` doesn't take effect when we call `focus` function of button with javascript, so we use `focus` here
+        @include button-focus-style;
     }
 
     // The border-radius is defined separately in the component, the default value is the same as popover

+ 2 - 1
packages/semi-foundation/tooltip/foundation.ts

@@ -314,6 +314,8 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
             this._adapter.togglePortalVisible(isVisible, () => {
                 if (isVisible) {
                     this._adapter.setInitialFocus();
+                } else {
+                    this._focusTrigger();
                 }
                 this._adapter.notifyVisibleChange(isVisible);
             });
@@ -893,7 +895,6 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
         const { trigger } = this.getProps();
         if (trigger !== 'custom') {
             this.hide();
-            this._focusTrigger();
         }
         this._adapter.notifyEscKeydown(event);
     }

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

@@ -73,6 +73,20 @@ export default class Button extends PureComponent<ButtonProps> {
         'aria-label': PropTypes.string,
     };
 
+    buttonRef: React.RefObject<HTMLButtonElement>;
+    constructor(props = {}) {
+        super(props);
+        this.buttonRef = React.createRef();
+    }
+
+    focus() {
+        this.buttonRef.current.focus();
+    }
+
+    blur() {
+        this.buttonRef.current.blur();
+    }
+
     render() {
         const {
             children,
@@ -120,6 +134,7 @@ export default class Button extends PureComponent<ButtonProps> {
                 onClick={this.props.onClick}
                 onMouseDown={this.props.onMouseDown}
                 style={style}
+                ref={this.buttonRef}
             >
                 {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
                 <span className={`${prefixCls}-content`} onClick={e => disabled && e.stopPropagation()}>

+ 14 - 2
packages/semi-ui/button/index.tsx

@@ -18,9 +18,21 @@ class Button extends React.PureComponent<ButtonProps> {
         ...BaseButton.propTypes,
         ...IconButton.propTypes,
     };
+
+    buttonRef: React.RefObject<IconButton | BaseButton>;
     constructor(props = {}) {
         super(props);
+        this.buttonRef = React.createRef();
+    }
+
+    focus() {
+        this.buttonRef.current.focus();
     }
+
+    blur() {
+        this.buttonRef.current.blur();
+    }
+
     render() {
         const props = { ...this.props };
         const hasIcon = Boolean(props.icon); 
@@ -28,9 +40,9 @@ class Button extends React.PureComponent<ButtonProps> {
         const isDisabled = Boolean(props.disabled);
 
         if (hasIcon || (isLoading && !isDisabled)) {
-            return <IconButton {...props} />;
+            return <IconButton {...this.props} ref={this.buttonRef as React.RefObject<IconButton>} />;
         } else {
-            return <BaseButton {...props} />;
+            return <BaseButton {...this.props} ref={this.buttonRef as React.RefObject<BaseButton>} />;
         }
     }
 }

+ 15 - 1
packages/semi-ui/iconButton/index.tsx

@@ -56,6 +56,20 @@ class IconButton extends PureComponent<IconButtonProps> {
         onMouseLeave: PropTypes.func,
     };
 
+    iconButtonRef: React.RefObject<Button>;
+    constructor(props = {}) {
+        super(props);
+        this.iconButtonRef = React.createRef();
+    }
+
+    focus() {
+        this.iconButtonRef.current.focus();
+    }
+
+    blur() {
+        this.iconButtonRef.current.blur();
+    }
+
     render() {
         const {
             children: originChildren,
@@ -120,7 +134,7 @@ class IconButton extends PureComponent<IconButtonProps> {
             [`${prefixCls}-loading`]: loading,
         });
         return (
-            <Button {...otherProps} className={iconBtnCls} theme={theme} style={style}>
+            <Button {...otherProps} className={iconBtnCls} theme={theme} style={style} ref={this.iconButtonRef}>
                 {finalChildren}
             </Button>
         );

+ 45 - 1
packages/semi-ui/popconfirm/_story/popconfirm.stories.js

@@ -5,6 +5,7 @@ import Button from '../../button';
 import Input from '../../input';
 import Table from '../../table';
 import Toast from '../../toast';
+import { Space } from '../../index';
 
 import TypesConfrimDemo from './TypesConfirm';
 import DynamicDisableDemo from './DynamicDisable';
@@ -167,4 +168,47 @@ export const ClickOutSideDemo = () => {
 
 ClickOutSideDemo.story = {
   name: 'ClickOutSideDemo',
-};
+};
+
+export const KeyboardAndFocus = () => {
+  return (
+    <Space>
+      <div data-cy="initial-focus-confirm">
+        <Popconfirm
+            title="确定是否要保存此修改?"
+            content="此修改将不可逆"
+            okButtonProps={{
+              initialFocus: true,
+              type: 'danger',
+              className: 'test-ok',
+            }}
+        >
+            <Button>确认聚焦</Button>
+        </Popconfirm>
+      </div>
+      <div data-cy="initial-focus-cancel">
+        <Popconfirm
+            title="确定是否要保存此修改?"
+            content="此修改将不可逆"
+            cancelButtonProps={{
+              initialFocus: true,
+              className: 'test-cancel',
+            }}
+        >
+            <Button>取消聚焦</Button>
+        </Popconfirm>
+      </div>
+      <div data-cy="initial-focus-content">
+        <Popconfirm
+            title="确定是否要保存此修改?"
+            content={({ initialFocusRef }) => {
+              return (<input ref={initialFocusRef} placeholder="focus here" />);
+            }}
+        >
+            <Button>内容聚焦</Button>
+        </Popconfirm>
+      </div>
+    </Space>
+  );
+};
+KeyboardAndFocus.storyName = "键盘和焦点";

+ 51 - 15
packages/semi-ui/popconfirm/index.tsx

@@ -2,32 +2,33 @@
 import React from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
-import { noop, get } from 'lodash';
+import { noop, get, isFunction, omit } from 'lodash';
 import { cssClasses, numbers } from '@douyinfe/semi-foundation/popconfirm/constants';
 import PopconfirmFoundation, { PopconfirmAdapter } from '@douyinfe/semi-foundation/popconfirm/popconfirmFoundation';
 import { IconClose, IconAlertTriangle } from '@douyinfe/semi-icons';
 import BaseComponent from '../_base/baseComponent';
 import Popover, { PopoverProps } from '../popover';
-import { Position, Trigger } from '../tooltip';
+import { Position, Trigger, RenderContentProps, TooltipProps } from '../tooltip';
 import Button, { ButtonProps } from '../button';
 import { Type as ButtonType } from '../button/Button';
 import ConfigContext, { ContextValue } from '../configProvider/context';
 import LocaleConsumer from '../locale/localeConsumer';
 import { Locale as LocaleObject } from '../locale/interface';
 import '@douyinfe/semi-foundation/popconfirm/popconfirm.scss';
+import { BASE_CLASS_PREFIX } from '@douyinfe/semi-foundation/base/constants';
 import { Motion } from '../_base/base';
 
 export interface PopconfirmProps extends PopoverProps {
     cancelText?: string;
-    cancelButtonProps?: ButtonProps;
+    cancelButtonProps?: CancelButtonProps;
     cancelType?: ButtonType;
-    content?: React.ReactNode;
+    content?: TooltipProps['content'];
     defaultVisible?: boolean;
     disabled?: boolean;
     icon?: React.ReactNode;
     okText?: string;
     okType?: ButtonType;
-    okButtonProps?: ButtonProps;
+    okButtonProps?: OkButtonProps;
     motion?: Motion;
     title?: React.ReactNode;
     visible?: boolean;
@@ -45,6 +46,14 @@ export interface PopconfirmState {
     visible: boolean;
 }
 
+export interface CancelButtonProps extends ButtonProps {
+    initialFocus?: boolean;
+}
+
+export interface OkButtonProps extends ButtonProps {
+    initialFocus?: boolean;
+}
+
 interface PopProps {
     [x: string]: any;
 }
@@ -54,7 +63,7 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
     static propTypes = {
         motion: PropTypes.oneOfType([PropTypes.bool, PropTypes.func, PropTypes.object]),
         disabled: PropTypes.bool,
-        content: PropTypes.any,
+        content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
         title: PropTypes.any,
         prefixCls: PropTypes.string,
         className: PropTypes.string,
@@ -139,16 +148,41 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
 
     stopImmediatePropagation = (e: React.SyntheticEvent): void => e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation();
 
-    renderControls() {
-        const { okText, cancelText, okType, cancelType, cancelButtonProps, okButtonProps } = this.props;
+    renderControls = ({ initialFocusRef }: { initialFocusRef: RenderContentProps<Button>['initialFocusRef'] }) => {
+        const { okText, cancelText, okType, cancelType, cancelButtonProps, okButtonProps, prefixCls } = this.props;
+        const cancelInitialFocus = get(cancelButtonProps, 'initialFocus');
+        const okInitialFocus = get(okButtonProps, 'initialFocus');
+        const omitCancelButtonProps = omit(cancelButtonProps, 'initialFocus');
+        const omitOkButtonProps = omit(okButtonProps, 'initialFocus');
         return (
             <LocaleConsumer componentName="Popconfirm">
                 {(locale: LocaleObject['Popconfirm'], localeCode: string) => (
                     <>
-                        <Button type={cancelType} onClick={this.handleCancel} {...cancelButtonProps}>
+                        <Button
+                            type={cancelType} 
+                            onClick={this.handleCancel} 
+                            ref={cancelInitialFocus ? initialFocusRef : null}
+                            {...omitCancelButtonProps}
+                            className={
+                                cls(omitCancelButtonProps.className, {
+                                    [`${BASE_CLASS_PREFIX}-button-initial-focus`]: cancelInitialFocus,
+                                })
+                            }
+                        >
                             {cancelText || get(locale, 'cancel')}
                         </Button>
-                        <Button type={okType} theme="solid" onClick={this.handleConfirm} {...okButtonProps}>
+                        <Button
+                            type={okType}
+                            theme="solid"
+                            onClick={this.handleConfirm}
+                            ref={okInitialFocus ? initialFocusRef : null}
+                            {...omitOkButtonProps}
+                            className={
+                                cls(omitOkButtonProps.className, {
+                                    [`${BASE_CLASS_PREFIX}-button-initial-focus`]: okInitialFocus,
+                                })
+                            }
+                        >
                             {okText || get(locale, 'confirm')}
                         </Button>
                     </>
@@ -157,7 +191,7 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
         );
     }
 
-    renderConfirmPopCard() {
+    renderConfirmPopCard = ({ initialFocusRef }: { initialFocusRef: RenderContentProps<any>['initialFocusRef'] }) => {
         const { content, title, className, style, cancelType, icon, prefixCls } = this.props;
         const { direction } = this.context;
         const popCardCls = cls(
@@ -171,6 +205,7 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
         const showContent = content !== null || typeof content !== 'undefined';
 
         return (
+            /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
             <div className={popCardCls} onClick={this.stopImmediatePropagation} style={style}>
                 <div className={`${prefixCls}-inner`}>
                     <div className={`${prefixCls}-header`}>
@@ -179,7 +214,9 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
                         </i>
                         <div className={`${prefixCls}-header-body`}>
                             {showTitle ? <div className={`${prefixCls}-header-title`}>{title}</div> : null}
-                            {showContent ? <div className={`${prefixCls}-header-content`}>{content}</div> : null}
+                            {showContent ? <div className={`${prefixCls}-header-content`}>
+                                {isFunction(content) ? content({ initialFocusRef }) : content}
+                            </div> : null}
                         </div>
                         <Button
                             className={`${prefixCls}-btn-close`}
@@ -190,7 +227,7 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
                             onClick={this.handleCancel}
                         />
                     </div>
-                    <div className={`${prefixCls}-footer`}>{this.renderControls()}</div>
+                    <div className={`${prefixCls}-footer`}>{this.renderControls({ initialFocusRef })}</div>
                 </div>
             </div>
         );
@@ -215,7 +252,6 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
         }
 
         const { visible } = this.state;
-        const popContent = this.renderConfirmPopCard();
         const popProps: PopProps = {
             onVisibleChange: this.handleVisibleChange,
             className: cssClasses.POPOVER,
@@ -229,7 +265,7 @@ export default class Popconfirm extends BaseComponent<PopconfirmProps, Popconfir
         return (
             <Popover
                 {...attrs}
-                content={popContent}
+                content={this.renderConfirmPopCard}
                 visible={visible}
                 position={position}
                 {...popProps}

+ 25 - 0
packages/semi-ui/tooltip/_story/tooltip.stories.js

@@ -1007,3 +1007,28 @@ export const autoFocusContentDemo = () => {
     </div>
   );
 };
+
+export const KeyboardAndFocus = () => {
+  // container 需要设置 position: relative
+  const getPopupContainer = () => document.querySelector('#tooltip-container');
+
+  return (
+      <div style={{ width: '100%', height: '100%', overflow: 'hidden', position: 'relative' }} id="tooltip-container">
+          <div style={{ width: '150%', height: '150%', paddingLeft: 50, paddingTop: 50 }}>
+              <br />
+              <Tooltip content={'hi bytedance'} trigger="click" getPopupContainer={getPopupContainer}>
+                  <Button style={{ marginBottom: 20 }}>点击显示</Button>
+              </Tooltip>
+              <br />
+              <Tooltip content={'hi bytedance'} trigger="focus" getPopupContainer={getPopupContainer}>
+                  <Input style={{ width: 100, marginBottom: 20 }} placeholder="聚焦显示" />
+              </Tooltip>
+              <br />
+              <Tooltip content={'hi bytedance'} getPopupContainer={getPopupContainer}>
+                  <Button style={{ marginBottom: 20 }}>悬停显示</Button>
+              </Tooltip>
+          </div>
+      </div>
+  );
+};
+KeyboardAndFocus.storyName = "键盘和焦点";

+ 3 - 3
packages/semi-ui/tooltip/index.tsx

@@ -36,11 +36,11 @@ export interface ArrowBounding {
     height?: number;
 }
 
-export interface RenderContentProps {
-    initialFocusRef?: React.RefObject<HTMLElement>;
+export interface RenderContentProps<T = HTMLElement> {
+    initialFocusRef?: React.RefObject<T>;
 }
 
-export type RenderContent = (props: RenderContentProps) => React.ReactNode;
+export type RenderContent<T = HTMLElement> = (props: RenderContentProps<T>) => React.ReactNode;
 
 export interface TooltipProps extends BaseProps {
     children?: React.ReactNode;