浏览代码

feat(a11y): add keyboard and focus event to select (#950)

* feat(a11y): add keyboard and focus event to select

* test: update the cypress and jest test

* feat(a11y): add keyboard and focus event to select

* feat(a11y): add keyboard and focus event to select

* feat(a11y): add keyboard and focus event to select

* feat(a11y): add keyboard and focus event to select

* feat(a11y): add keyboard and focus event to select
YannLynn 3 年之前
父节点
当前提交
99f51a4340

+ 29 - 3
content/input/select/index-en-US.md

@@ -1286,7 +1286,7 @@ import { Select, Checkbox } from '@douyinfe/semi-ui';
 | clickToHide | When expanded, click on the selection box to automatically put away the drop-down list | boolean | false |
 | defaultValue | Originally selected value when component mount | string\|number\|array |  |
 | defaultOpen | Whether show dropdown when component mounted | boolean | false |
-| defaultActiveFirstOption | Whether to highlight the first option by default (press Enter to select directly) | boolean | false |
+| defaultActiveFirstOption | Whether to highlight the first option by default (press Enter to select directly) | boolean | true |
 | disabled | Whether disabled component | boolean | false |
 | dropdownClassName | ClassName of the pop-up layer | string |  |
 | dropdownMatchSelectWidth | Is the minimum width of the drop-down menu equal to Select | boolean | true |
@@ -1376,8 +1376,26 @@ import { Select, Checkbox } from '@douyinfe/semi-ui';
 - The role of the Select trigger is combobox, the role of the popup layer is listbox, and the role of the option is option
 - Select trigger has aria-haspopup, aria-expanded, and aria-controls properties, indicating the relationship between trigger and popup layer
 - When multiple selections are made, listbox aria-multiselectable is true, indicating that multiple selections are currently available
-- aria-selected is true when Option is selected; aria-disabled is true when Option is disabled
-
+- Aria-selected is true when Option is selected; aria-disabled is true when Option is disabled
+- The attribute aria-activedescendant ensures that the currently selected option is recognized when the narration is spoken(for more information, please refer to [Managing Focus in Composites Using aria-activedescendant](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant))
+
+### Keyboard and Focus
+**Select without Filter:**
+- After Select is focused, keyboard users can open the dropdown menu with the `Up Arrow` or `Down Arrow` or `Enter` keys and automatically focus on the first option in the dropdown menu (`defaultActiveFirstOption` defaults to true)
+- When the dropdown menu is open:
+  - Use `Esc` key or `Tab` key to close the menu
+  - Use `Up Arrow` or `Down Arrow` to toggle options
+  - The focused option can be selected with the Enter key and the panel is collapsed
+- When the focus is on the dropdown menu and the user uses an `innerBottomSlot` or `outerBottomSlot` attribute with a custom slot with an interactive element:
+  - You can use the `Tab` key to switch to these interactive elements
+  - When the focus is on the first interactive element of the custom slot, use `Shift` + `Tab` to return the focus to the Select box
+
+**Select with Filter function:**
+- When Select is focused, keyboard users can open dropdown menus with `Up Arrow` or `Down Arrow` or `Enter` keys. At this point, the focus is still on the Select box, the user can enter content, and can also use the `up arrow` or `down arrow` to switch options
+- When the dropdown menu is open: the keyboard interaction is the same as Select without the Filter function
+- When the focus is on the Select box, and the user uses an `innerBottomSlot` or `outerBottomSlot` property with a custom slot with an interactive element:
+  - You can use the `Tab` key to switch to these interactive elements
+  - When the focus is on the first interactive element of the custom slot, use `Shift` + `Tab` to return the focus to the Select box
 ## Design Tokens
 
 <DesignToken/>
@@ -1402,7 +1420,15 @@ MinWidth will be given, but width will not be written dead. If necessary, you ca
     allowCreate is mainly used for locally created scenarios. When this item is turned on, it is equivalent to forcibly taking over optionList/children, and will no longer respond to external updates to these two types of values. Otherwise, how the currently created options are combined with the latest props.optionList, and whether the strategy is overwritten or merged depends largely on the business scenario logic, and it is inappropriate to force presets by the component layer.
 
 -   **Why Semi's Select requires that the label must be unique, but not the value?**
+
     First of all, we must need a unique identifier to make a selection judgment. For almost all UI libraries, when using Select.Option, the minimum requirements will only require the two values of label and value to be passed in, instead of requiring a separate key (too cumbersome). Semi continues this setting.    
     So why is label instead of value in semi's select?  
     The label of the option is what the user perceives. From an interactive point of view, if there are two options that are exactly the same on the display, to the user’s perception, they look the same and cannot be distinguished, but the selected effects are different (for example, one value is 0, the other As 1), it is unreasonable. (Users' first reaction is often repeated, and there may be a bug)
 Unique label and repeated value are more common in daily use. For example, a selector that selects the company id based on the app name, value is the company id corresponding to the app, and label is the name of the app.
+
+- **Why is the blur event not fired after a radio selection option?**
+
+    Before V2.17.0, after Select radio is selected, the blur event of Select will be triggered.
+    After V2.17.0, Select has added A11y support, which will not trigger Select's blur event.
+    In single-selection selection, the Select floating layer is closed, and the focus is still on the trigger (at this time, the Select floating layer can be opened again by pressing the Enter key)
+    No matter single selection or multiple selection, press Esc, only the Select floating layer is closed, and the trigger keeps the focus (the Select floating layer can be opened again by pressing the Enter key at this time)

+ 27 - 5
content/input/select/index.md

@@ -1302,7 +1302,7 @@ import { Select, Checkbox } from '@douyinfe/semi-ui';
 | defaultValue             | 初始选中的值                                                                                                                                                                                              | string\|number\|array                 |                                   |
 | defaultOpen              | 是否默认展开下拉列表                                                                                                                                                                                      | boolean                               | false                             |
 | disabled                 | 是否禁用                                                                                                                                                                                                  | boolean                               | false                             |
-| defaultActiveFirstOption | 是否默认高亮第一个选项(按回车可直接选中)                                                                                                                                                                | boolean                               | false                             |
+| defaultActiveFirstOption | 是否默认高亮第一个选项(按回车可直接选中) <br/>**v2.17.0之后默认值从false变为true**                                                                                                                                                               | boolean                               | true                             |
 | dropdownClassName        | 弹出层的 className                                                                                                                                                                                        | string                                |                                   |
 | dropdownMatchSelectWidth | 下拉菜单最小宽度是否等于 Select                                                                                                                                                                           | boolean                               | true                              |
 | dropdownStyle            | 弹出层的样式                                                                                                                                                                                              | object                                |                                   |
@@ -1394,14 +1394,30 @@ import { Select, Checkbox } from '@douyinfe/semi-ui';
 | selectAll | 调用时可以选中所有Option | v1.18.0 |
 
 ## Accessibility
-
 ### ARIA
-
 - Select trigger 的 role 为 combobox,弹出层的 role 为 listbox,可选项的 role 为 option
 - Select trigger 具有 aria-haspopup、aria-expanded、aria-controls 属性,表示 trigger 与弹出层的关系
 - 多选时,listbox aria-multiselectable 为 true,表示当前可以多选
 - Option 选中时,aria-selected 为 true;当 Option 禁用时,aria-disabled 为 true
-
+- 属性aria-activedescendant能够保证在朗读旁白时识别到当前的选择的option(更多用法请参考[Managing Focus in Composites Using aria-activedescendant](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant))
+
+### 键盘和焦点
+**不带 Filter 功能的 Select:**  
+- Select 聚焦后,键盘用户可以通过 `上箭头` 或 `下箭头` 或 `Enter` 键打开下拉菜单,并将焦点自动聚焦到下拉菜单中的第一个选项上(`defaultActiveFirstOption` 默认为 true)
+- 当下拉菜单打开时:
+  - 使用 `Esc` 键或 `Tab` 键可以关闭菜单
+  - 使用 `上箭头` 或 `下箭头` 可以切换选项
+  - 被聚焦的选项可以通过 Enter 键选中,并收起面板
+- 当焦点在下拉菜单中,且用户使用的 `innerBottomSlot` 或 `outerBottomSlot` 属性的自定义 slot 中含有可交互元素时:
+  - 可以使用 `Tab` 键切换到这些可交互元素上
+  - 当焦点在自定义 slot 的首个可交互元素上时,使用 `Shift` + `Tab` ,焦点回到 Select 框上
+
+**带 Filter 功能的 Select:**  
+- Select 聚焦后,键盘用户可以通过 `上箭头` 或 `下箭头` 或 `Enter` 键打开下拉菜单。此时焦点仍然处于 Select 框,用户可以输入内容,同时也能使用 `上箭头` 或 `下箭头` 切换选项
+- 当下拉菜单打开时:键盘交互与不带 Filter 功能的 Select 一致
+- 当焦点在 Select 框上,且用户使用的 `innerBottomSlot` 或 `outerBottomSlot` 属性的自定义 slot 中含有可交互元素时:
+  - 可以使用 `Tab` 键切换到这些可交互元素上
+  - 当焦点在自定义 slot 的首个可交互元素上时,使用 `Shift` + `Tab` ,焦点回到 Select 框上
 
 ## 设计变量
 <DesignToken/>
@@ -1436,7 +1452,13 @@ import { Select, Checkbox } from '@douyinfe/semi-ui';
             <Option label='abc' value='bytedance' />
           </Select>
         ```
-
+        
+-   **为什么单选选择选项后没有触发blur事件?**
+    - 在V2.17.0前,Select 单选选择后,会触发 Select 的 blur事件。
+    - 在V2.17.0后,Select 增加了A11y支持,不会触发 Select 的 blur事件。
+        - 单选选择中,Select 浮层关闭,依然保持焦点在 trigger(此时可以通过 Enter 回车键再次打开 Select 浮层)
+        - 无论单选或多选,按下 Esc,仅 Select 浮层关闭,trigger 保持焦点(此时可以通过 Enter 回车键再次打开 Select 浮层)
+        
 <!-- ## 相关物料
 
 ```material

+ 7 - 2
cypress/integration/select.spec.js

@@ -29,17 +29,22 @@ describe('Select', () => {
         cy.get('input').eq(0).type('{downArrow}');
         cy.get('input').eq(0).type('{downArrow}');
         cy.get('input').eq(0).type('{enter}');
-        cy.get('.semi-select-selection-text').eq(0).should('have.text', 'opts');
+        cy.get('.semi-select-selection-text').eq(0).should('have.text', 'opt1');
     });
 
     it('clickOutSide, should hide', () => {
-        cy.visit('http://127.0.0.1:6006/iframe.html?path=/story/select--select-filter-single');
+        cy.visit('http://127.0.0.1:6006/iframe.html?path=/story/select--select-filter-single', {
+            onBeforeLoad(win) {
+                cy.stub(win.console, 'log').as('consoleLog');
+            },
+        });
         cy.get('.semi-select').eq(0).click();
         // should show now
         cy.get('.semi-select-option-list').should('exist');
         // should hide after click empty area
         cy.get('h5').eq(0).click();
         cy.get('.semi-select-option-list').should('not.exist');
+        cy.get('@consoleLog').should('be.calledWith', 'onBlur');
     });
 
     // it('should trigger onSearch when click x icon', () => {

+ 149 - 36
packages/semi-foundation/select/foundation.ts

@@ -7,6 +7,7 @@ import warning from '../utils/warning';
 import isNullOrUndefined from '../utils/isNullOrUndefined';
 import { BasicOptionProps } from './optionFoundation';
 import isEnterPress from '../utils/isEnterPress';
+import { handlePrevent } from '../utils/a11y';
 
 export interface SelectAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     getTriggerWidth(): number;
@@ -45,6 +46,11 @@ export interface SelectAdapter<P = Record<string, any>, S = Record<string, any>>
     notifyMouseEnter(event: any): void;
     updateHovering(isHover: boolean): void;
     updateScrollTop(index?: number): void;
+    getContainer(): any;
+    getFocusableElements(node: any): any[];
+    getActiveElement(): any;
+    setIsFocusInContainer(isFocusInContainer: boolean): void;
+    getIsFocusInContainer(): boolean;
 }
 
 type LabelValue = string | number;
@@ -77,11 +83,18 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
     }
 
     focus() {
-        this._focusTrigger();
         const isFilterable = this._isFilterable();
+        const isMultiple = this._isMultiple();
         this._adapter.updateFocusState(true);
-        if (isFilterable) {
+        this._adapter.setIsFocusInContainer(false);
+        if (isFilterable && isMultiple) {
+            // when filter and multiple, only focus input
+            this.focusInput();
+        } else if (isFilterable && !isMultiple){
+            // when filter and not multiple, only show input and focus input
             this.toggle2SearchInput(true);
+        } else {
+            this._focusTrigger();
         }
     }
 
@@ -92,7 +105,7 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
 
     destroy() {
         this._adapter.unregisterClickOutsideHandler();
-        this.unBindKeyBoardEvent();
+        // this.unBindKeyBoardEvent();
     }
 
     _setDropdownWidth() {
@@ -337,6 +350,8 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
 
         this._adapter.registerClickOutsideHandler((e: MouseEvent) => {
             this.close(e);
+            this._notifyBlur(e);
+            this._adapter.updateFocusState(false);
         });
     }
 
@@ -344,24 +359,27 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
         if (isShow) {
             this._adapter.toggleInputShow(isShow, () => this.focusInput());
         } else {
+            // only when choose the option and close the panel, the input can be hide
             this._adapter.toggleInputShow(isShow, () => undefined);
         }
     }
 
     close(e?: any) {
+        // to support A11y, closing the panel trigger does not necessarily lose focus
         const isFilterable = this._isFilterable();
         if (isFilterable) {
-            this.unBindKeyBoardEvent();
+            // this.unBindKeyBoardEvent();
             this.clearInput();
             this.toggle2SearchInput(false);
         }
 
         this._adapter.closeMenu();
         this._adapter.notifyDropdownVisibleChange(false);
-        this.unBindKeyBoardEvent();
-        this._notifyBlur(e);
+        this._adapter.setIsFocusInContainer(false);
+        // this.unBindKeyBoardEvent();
+        // this._notifyBlur(e);
         this._adapter.unregisterClickOutsideHandler();
-        this._adapter.updateFocusState(false);
+        // this._adapter.updateFocusState(false);
     }
 
     onSelect(option: BasicOptionProps, optionIndex: number, event: MouseEvent | KeyboardEvent) {
@@ -378,6 +396,7 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
         const isMultiple = this._isMultiple();
         if (!isMultiple) {
             this._handleSingleSelect(option, event);
+            this._focusTrigger();
         } else {
             this._handleMultipleSelect(option, event);
         }
@@ -519,6 +538,7 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
     focusInput() {
         this._adapter.focusInput();
         this._adapter.updateFocusState(true);
+        this._adapter.setIsFocusInContainer(false);
     }
 
     handleInputChange(sugInput: string) {
@@ -636,9 +656,10 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
 
     _handleKeyDown(event: KeyboardEvent) {
         const key = event.keyCode;
+        const { loading, filter, multiple, disabled } = this.getProps();
         const { isOpen } = this.getStates();
-        const { loading } = this.getProps();
-        if (!isOpen || loading) {
+
+        if (loading || disabled) {
             return;
         }
         switch (key) {
@@ -660,13 +681,30 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
             case KeyCode.ENTER:
                 // internal-issues:302
                 // prevent trigger form’s submit when use in form
-                event.preventDefault();
-                event.stopPropagation();
+                handlePrevent(event);
                 this._handleEnterKeyDown(event);
                 break;
             case KeyCode.ESC:
+                isOpen && this.close(event);
+                filter && !multiple && this._focusTrigger();
+                break;
+            case KeyCode.TAB:
+                // check if slot have focusable element
+                this._handleTabKeyDown(event);
+                break;
+            default:
+                break;
+        }
+    }
+
+    handleContainerKeyDown(event: any) {
+        // when focus in contanier, handle the key down
+        const key = event.keyCode;
+        const { isOpen } = this.getStates();
+
+        switch (key) {
             case KeyCode.TAB:
-                this.close(event);
+                isOpen && this._handleTabKeyDown(event);
                 break;
             default:
                 break;
@@ -717,26 +755,91 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
     }
 
     _handleArrowKeyDown(offset: number) {
-        this._getEnableFocusIndex(offset);
+        const { isOpen } = this.getStates();
+        isOpen ? this._getEnableFocusIndex(offset) : this.open();
+    }
+
+    _handleTabKeyDown(event: any){
+        const { isOpen } = this.getStates();
+        this._adapter.updateFocusState(false);
+
+        if (isOpen){
+            const container = this._adapter.getContainer();
+            const focusableElements = this._adapter.getFocusableElements(container);
+            const focusableNum = focusableElements.length;
+
+            if (focusableNum > 0){
+                // Shift + Tab will move focus backward
+                if (event.shiftKey) {
+                    this._handlePanelOpenShiftTabKeyDown(focusableElements, event);
+                } else {
+                    this._handlePanelOpenTabKeyDown(focusableElements, event);  
+                }
+            } else {
+                // there are no focusable elements inside the container, tab to next element and trigger blur
+                this.close();
+                this._notifyBlur(event);
+            }
+        } else {
+            // tab or shift tab to next element and trigger blur
+            this._notifyBlur(event);
+        }
+    }
+
+    _handlePanelOpenTabKeyDown(focusableElements: any[], event: any) {
+        const activeElement = this._adapter.getActiveElement();
+        const isFocusInContainer = this._adapter.getIsFocusInContainer();
+        if (!isFocusInContainer){
+            // focus in trigger, set next focus to the first element in container
+            focusableElements[0].focus();
+            this._adapter.setIsFocusInContainer(true);
+            handlePrevent(event);
+        } else if (activeElement === focusableElements[focusableElements.length - 1]) {
+            // focus in the last element in container, focus back to trigger and close panel
+            this._focusTrigger(); 
+            this.close();
+            handlePrevent(event);
+        }
+    }
+
+    _handlePanelOpenShiftTabKeyDown(focusableElements: any[], event: any) {
+        const activeElement = this._adapter.getActiveElement();
+        const isFocusInContainer = this._adapter.getIsFocusInContainer();
+
+        if (!isFocusInContainer) {
+            // focus in trigger, close the panel, shift tab to previe element and trigger blur
+            this.close();
+            this._notifyBlur(event);
+        } else if (activeElement === focusableElements[0]) {
+            // focus in the first element in container, focus back to trigger
+            this._focusTrigger(); 
+            this._adapter.setIsFocusInContainer(false);
+            handlePrevent(event);
+        }
     }
 
     _handleEnterKeyDown(event: KeyboardEvent) {
         const { isOpen, options, focusIndex } = this.getStates();
-        if (focusIndex !== -1) {
-            const visibleOptions = options.filter((item: BasicOptionProps) => item._show);
-            const { length } = visibleOptions;
-            // fix issue 1201
-            if (length <= focusIndex) {
-                return;
-            }
-            if (visibleOptions && length) {
-                const selectedOption = visibleOptions[focusIndex];
-                if (selectedOption.disabled) {
+        if (!isOpen){
+            this.open();
+        } else {
+            if (focusIndex !== -1) {
+                const visibleOptions = options.filter((item: BasicOptionProps) => item._show);
+                const { length } = visibleOptions;
+                // fix issue 1201
+                if (length <= focusIndex) {
                     return;
                 }
-                this.onSelect(selectedOption, focusIndex, event);
+                if (visibleOptions && length) {
+                    const selectedOption = visibleOptions[focusIndex];
+                    if (selectedOption.disabled) {
+                        return;
+                    }
+                    this.onSelect(selectedOption, focusIndex, event);
+                }
+            } else {
+                this.close();
             }
-        } else if (isOpen) {
         }
     }
 
@@ -856,15 +959,16 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
     }
 
     // Scenes that may trigger blur:
-    //  1、clickOutSide
-    //  2、click option / press enter, and then select complete(when multiple is false
-    //  3、press esc when dropdown list open
+    // 1、clickOutSide
+    // 2、 tab to next element/ shift tab to previous element
+    // 3、[remove when add a11y] click option / press enter, and then select complete(when multiple is false 
+    // 4、[remove when add a11y] press esc when dropdown list open 
     _notifyBlur(e: FocusEvent) {
         this._adapter.notifyBlur(e);
     }
 
     // Scenes that may trigger focus:
-    //  1、click selection
+    // 1、click selection
     _notifyFocus(e: FocusEvent) {
         this._adapter.notifyFocus(e);
     }
@@ -910,23 +1014,32 @@ export default class SelectFoundation extends BaseFoundation<SelectAdapter> {
         this._adapter.notifyListScroll(e);
     }
 
-    // handleTriggerFocus(e) {
-    //     console.log('handleTriggerFocus');
-    //     this._adapter.updateFocusState(true);
-    // }
+    handleTriggerFocus(e) {
+        this.bindKeyBoardEvent();
+        this._adapter.updateFocusState(true);
+        this._adapter.setIsFocusInContainer(false);
+    }
 
     handleTriggerBlur(e: FocusEvent) {
         this._adapter.updateFocusState(false);
         const { filter, autoFocus } = this.getProps();
         const { isOpen, isFocus } = this.getStates();
-        // Under normal circumstances, blur will be accompanied by dropdown close, so the notify of blur can be called uniformly in close
-        // But when autoFocus, because dropdown is not expanded, you need to listen for the trigger's blur and trigger the notify callback
+        // Under normal circumstances, blur will be accompanied by clickOutsideHandler, so the notify of blur can be called uniformly in clickOutsideHandler
+        // But when autoFocus, because clickOutsideHandler is not register, you need to listen for the trigger's blur and trigger the notify callback
         if (autoFocus && isFocus && !isOpen) {
-            // blur when autoFocus & not open dropdown yet
             this._notifyBlur(e);
         }
     }
 
+    handleInputBlur(e: any) {
+        const { filter, autoFocus } = this.getProps();
+        const isMultiple = this._isMultiple();
+        if (autoFocus && filter && !isMultiple ) {
+            // under this condition, when input blur, hide the input
+            this.toggle2SearchInput(false);
+        }
+    }
+
     selectAll() {
         const { options } = this.getStates();
         const { onChangeWithObject } = this.getProps();

+ 9 - 6
packages/semi-foundation/select/select.scss

@@ -27,11 +27,6 @@ $multiple: #{$module}-multiple;
         border: $width-select-border-hover solid $color-select-border-hover;
     }
 
-    &:active {
-        background-color: $color-select-bg-active;
-        border: $width-select-border-active solid $color-select-border-active;
-    }
-
     &:focus {
         border: $width-select-border-focus solid $color-select-border-focus;
         outline: 0;
@@ -59,6 +54,12 @@ $multiple: #{$module}-multiple;
             background-color: $color-select-bg-default;
             border: $border-thickness-control-focus solid $color-select_default-border-focus;
         }
+
+        // when click the trigger, trigger get the focus state, active style should take effect
+        &:active {
+            background-color: $color-select-bg-active;
+            border: $width-select-border-active solid $color-select-border-active;
+        }
     }
 
     &-warning {
@@ -111,7 +112,9 @@ $multiple: #{$module}-multiple;
         }
 
         &:focus {
-            border: $border-thickness-control-focus solid $color-select_default-border-focus;
+            // border: $border-thickness-control-focus solid $color-select_default-border-focus;
+            // when select is disabled, the border should not have active color
+            border: $border-thickness-control-focus solid transparent;
         }
 
         .#{$module}-selection,

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

@@ -856,7 +856,7 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
     }
 
     _handleTriggerKeydown(event: any) {
-        const { closeOnEsc } = this.getProps();
+        const { closeOnEsc, disableArrowKeyDown } = this.getProps();
         const container = this._adapter.getContainer();
         const focusableElements = this._adapter.getFocusableElements(container);
         const focusableNum = focusableElements.length;
@@ -867,10 +867,11 @@ export default class Tooltip<P = Record<string, any>, S = Record<string, any>> e
                 closeOnEsc && this._handleEscKeyDown(event);
                 break;
             case "ArrowUp":
-                focusableNum && this._handleTriggerArrowUpKeydown(focusableElements, event);
+                // when disableArrowKeyDown is true, disable tooltip's arrow keyboard event action
+                !disableArrowKeyDown && focusableNum && this._handleTriggerArrowUpKeydown(focusableElements, event);
                 break;
             case "ArrowDown":
-                focusableNum && this._handleTriggerArrowDownKeydown(focusableElements, event);
+                !disableArrowKeyDown && focusableNum && this._handleTriggerArrowDownKeydown(focusableElements, event);
                 break;
             default:
                 break;

+ 1 - 0
packages/semi-ui/popover/index.tsx

@@ -82,6 +82,7 @@ class Popover extends React.PureComponent<PopoverProps, PopoverState> {
         arrowBounding: PropTypes.object,
         prefixCls: PropTypes.string,
         guardFocus: PropTypes.bool,
+        disableArrowKeyDown: PropTypes.bool,
     };
 
     static defaultProps = {

+ 5 - 3
packages/semi-ui/select/__test__/select.test.js

@@ -876,9 +876,9 @@ describe('Select', () => {
         // Since there is no mechanism such as event bubbling in enzyme + jsdom, the blur event can only be triggered manually on the blur element,
         // and the blur of the `a element` cannot be achieved through the focus `b element`.
 
-        // blur usually call when popover close, so use select instance close() method to mock blur click like use in browser
+        // Adapt to A11y requirements, close the panel will not call the onBlur func 
         select.instance().close();
-        expect(spyOnBlur.callCount).toEqual(1);
+        expect(spyOnBlur.callCount).toEqual(0);
         select.unmount();
     });
 
@@ -1076,10 +1076,12 @@ describe('Select', () => {
         };
         let select = getSelect(props);
         // press ⬇️
+        // since the defaultActiveFirstOption default to be true, after ⬇️, the second option focused
         select.find(`.${BASE_CLASS_PREFIX}-select`).simulate('keydown', { keyCode: keyCode.DOWN });
-        expect(select.find(`.${BASE_CLASS_PREFIX}-select-option`).at(0).hasClass(`${BASE_CLASS_PREFIX}-select-option-focused`)).toBe(true);
+        expect(select.find(`.${BASE_CLASS_PREFIX}-select-option`).at(1).hasClass(`${BASE_CLASS_PREFIX}-select-option-focused`)).toBe(true);
         // press ⬆️
         select.find(`.${BASE_CLASS_PREFIX}-select`).simulate('keydown', { keyCode: keyCode.UP });
+        select.find(`.${BASE_CLASS_PREFIX}-select`).simulate('keydown', { keyCode: keyCode.UP });
         expect(select.find(`.${BASE_CLASS_PREFIX}-select-option`).at(defaultList.length-1).hasClass(`${BASE_CLASS_PREFIX}-select-option-focused`)).toBe(true);
         // press ESC
         select.find(`.${BASE_CLASS_PREFIX}-select`).simulate('keydown', { keyCode: keyCode.ESC });

+ 65 - 30
packages/semi-ui/select/index.tsx

@@ -24,7 +24,7 @@ import OptionGroup from './optionGroup';
 import Spin from '../spin';
 import Trigger from '../trigger';
 import { IconChevronDown, IconClear } from '@douyinfe/semi-icons';
-import { isSemiIcon } from '../_utils';
+import { isSemiIcon, getFocusableElements, getActiveElement } from '../_utils';
 import { Subtract } from 'utility-types';
 
 import warning from '@douyinfe/semi-foundation/utils/warning';
@@ -177,6 +177,7 @@ export interface SelectState {
     keyboardEventSet: any; // {}
     optionGroups: Array<any>;
     isHovering: boolean;
+    isFocusInContainer: boolean;
 }
 
 // Notes: Use the label of the option as the identifier, that is, the option in Select, the value is allowed to be the same, but the label must be unique
@@ -304,13 +305,13 @@ class Select extends BaseComponent<SelectProps, SelectState> {
         onListScroll: noop,
         maxHeight: 300,
         dropdownMatchSelectWidth: true,
-        defaultActiveFirstOption: false,
+        defaultActiveFirstOption: true, // In order to meet the needs of A11y, change to true
         showArrow: true,
         showClear: false,
         remote: false,
         autoAdjustOverflow: true,
         autoClearSearchValue: true,
-        arrowIcon: <IconChevronDown />
+        arrowIcon: <IconChevronDown aria-label='' />
         // Radio selection is different from the default renderSelectedItem for multiple selection, so it is not declared here
         // renderSelectedItem: (optionNode) => optionNode.label,
         // The default creator rendering is related to i18, so it is not declared here
@@ -319,9 +320,11 @@ class Select extends BaseComponent<SelectProps, SelectState> {
 
     inputRef: React.RefObject<HTMLInputElement>;
     triggerRef: React.RefObject<HTMLDivElement>;
+    optionContainerEl: React.RefObject<HTMLDivElement>;
     optionsRef: React.RefObject<any>;
     virtualizeListRef: React.RefObject<any>;
     selectOptionListID: string;
+    selectID: string;
     clickOutsideHandler: (e: MouseEvent) => void;
     foundation: SelectFoundation;
     context: ContextValue;
@@ -341,13 +344,16 @@ class Select extends BaseComponent<SelectProps, SelectState> {
             keyboardEventSet: {},
             optionGroups: [],
             isHovering: false,
+            isFocusInContainer: false,
         };
         /* Generate random string */
         this.selectOptionListID = '';
+        this.selectID = '';
         this.virtualizeListRef = React.createRef();
         this.inputRef = React.createRef();
         this.triggerRef = React.createRef();
         this.optionsRef = React.createRef();
+        this.optionContainerEl = React.createRef();
         this.clickOutsideHandler = null;
         this.onSelect = this.onSelect.bind(this);
         this.onClear = this.onClear.bind(this);
@@ -355,7 +361,6 @@ class Select extends BaseComponent<SelectProps, SelectState> {
         this.onMouseLeave = this.onMouseLeave.bind(this);
         this.renderOption = this.renderOption.bind(this);
         this.onKeyPress = this.onKeyPress.bind(this);
-        this.onClearBtnEnterPress = this.onClearBtnEnterPress.bind(this);
 
         this.foundation = new SelectFoundation(this.adapter);
 
@@ -370,6 +375,8 @@ class Select extends BaseComponent<SelectProps, SelectState> {
         );
     }
 
+    setOptionContainerEl = (node: HTMLDivElement) => (this.optionContainerEl = { current: node });
+
     get adapter(): SelectAdapter<SelectProps, SelectState> {
         const keyboardAdapter = {
             registerKeyDown: (cb: () => void) => {
@@ -536,6 +543,21 @@ class Select extends BaseComponent<SelectProps, SelectState> {
 
                 }
             },
+            getContainer: () => {
+                return this.optionContainerEl && this.optionContainerEl.current;
+            },
+            getFocusableElements: (node: HTMLDivElement) => {
+                return getFocusableElements(node);
+            },
+            getActiveElement: () => {
+                return getActiveElement();
+            },
+            setIsFocusInContainer: (isFocusInContainer: boolean) => {
+                this.setState({ isFocusInContainer });
+            },
+            getIsFocusInContainer: () => {
+                return this.state.isFocusInContainer;
+            },
             updateScrollTop: (index?: number) => {
                 // eslint-disable-next-line max-len
                 let optionClassName = `.${prefixcls}-option-selected`;
@@ -565,6 +587,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
     componentDidMount() {
         this.foundation.init();
         this.selectOptionListID = getUuidShort();
+        this.selectID = this.props.id || getUuidShort();
     }
 
     componentWillUnmount() {
@@ -595,13 +618,13 @@ class Select extends BaseComponent<SelectProps, SelectState> {
     handleInputChange = (value: string) => this.foundation.handleInputChange(value);
 
     renderInput() {
-        const { size, multiple, disabled, inputProps } = this.props;
+        const { size, multiple, disabled, inputProps, filter } = this.props;
         const inputPropsCls = get(inputProps, 'className');
         const inputcls = cls(`${prefixcls}-input`, {
             [`${prefixcls}-input-single`]: !multiple,
             [`${prefixcls}-input-multiple`]: multiple,
         }, inputPropsCls);
-        const { inputValue } = this.state;
+        const { inputValue, focusIndex } = this.state;
 
         const selectInputProps: Record<string, any> = {
             value: inputValue,
@@ -623,11 +646,18 @@ class Select extends BaseComponent<SelectProps, SelectState> {
             <Input
                 ref={this.inputRef as any}
                 size={size}
+                aria-activedescendant={focusIndex !== -1 ? `${this.selectID}-option-${focusIndex}`: ''}
                 onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
+                    // if multiple and filter, when use tab key to let select get focus
+                    // need to manual update state isFocus to let the focus style take effect
+                    if (multiple && Boolean(filter)){
+                        this.setState({ isFocus: true });
+                    }
                     // prevent event bubbling which will fire trigger onFocus event
                     e.stopPropagation();
                     // e.nativeEvent.stopImmediatePropagation();
                 }}
+                onBlur={e => this.foundation.handleInputBlur(e)}
                 {...selectInputProps}
             />
         );
@@ -666,10 +696,6 @@ class Select extends BaseComponent<SelectProps, SelectState> {
         this.foundation.handleClearClick(e as any);
     }
 
-    /* istanbul ignore next */
-    onClearBtnEnterPress(e: React.KeyboardEvent) {
-        this.foundation.handleClearBtnEnterPress(e as any);
-    }
 
     renderEmpty() {
         return <Option empty={true} emptyContent={this.props.emptyContent} />;
@@ -712,6 +738,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
                     key={option.key || option.label as string + option.value as string + optionIndex}
                     renderOptionItem={renderOptionItem}
                     inputValue={inputValue}
+                    id={`${this.selectID}-option-${optionIndex}`}
                 >
                     {option.label}
                 </Option>
@@ -837,7 +864,14 @@ class Select extends BaseComponent<SelectProps, SelectState> {
 
         const isEmpty = !options.length || !options.some(item => item._show);
         return (
-            <div id={`${prefixcls}-${this.selectOptionListID}`} className={dropdownClassName} style={style}>
+            // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+            <div 
+                id={`${prefixcls}-${this.selectOptionListID}`} 
+                className={dropdownClassName} 
+                style={style} 
+                ref={this.setOptionContainerEl} 
+                onKeyDown={e => this.foundation.handleContainerKeyDown(e)}
+            >
                 {outerTopSlot}
                 <div
                     style={{ maxHeight: `${maxHeight}px` }}
@@ -930,7 +964,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
             };
             if (isRenderInTag) {
                 return (
-                    <Tag {...basic} color="white" size={size || 'large'} key={value}>
+                    <Tag {...basic} color="white" size={size || 'large'} key={value} tabIndex={-1}>
                         {content}
                     </Tag>
                 );
@@ -956,7 +990,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
 
         const NotOneLine = !maxTagCount; // Multiple lines (that is, do not set maxTagCount), do not use TagGroup, directly traverse with Tag, otherwise Input cannot follow the correct position
 
-        const tagContent = NotOneLine ? tags : <TagGroup<"custom"> tagList={tags} maxTagCount={n} restCount={maxTagCount ? selectedItems.length - maxTagCount : undefined} size="large" mode="custom" />;
+        const tagContent = NotOneLine ? tags : <TagGroup<"custom"> tagList={tags} maxTagCount={n} restCount={maxTagCount ? selectedItems.length - maxTagCount : undefined} size="large" mode="custom"/>;
 
         return (
             <>
@@ -1055,7 +1089,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
             arrowIcon,
         } = this.props;
 
-        const { selections, isOpen, keyboardEventSet, inputValue, isHovering, isFocus } = this.state;
+        const { selections, isOpen, keyboardEventSet, inputValue, isHovering, isFocus, showInput, focusIndex } = this.state;
         const useCustomTrigger = typeof triggerRender === 'function';
         const filterable = Boolean(filter); // filter(boolean || function)
         const selectionCls = useCustomTrigger ?
@@ -1109,32 +1143,31 @@ class Select extends BaseComponent<SelectProps, SelectState> {
                     </div>
                 </Fragment>,
                 <Fragment key="clearicon">
-                    {showClear ? (
-                        <div
-                            role="button"
-                            aria-label="Clear selected value"
-                            tabIndex={0}
-                            className={cls(`${prefixcls}-clear`)}
-                            onClick={this.onClear}
-                            onKeyPress={this.onClearBtnEnterPress}
-                        >
-                            <IconClear />
-                        </div>
-                    ) : arrowContent}
+                    {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
+                    {showClear ? ( <div className={cls(`${prefixcls}-clear`)} onClick={this.onClear}><IconClear /></div>) : arrowContent}
                 </Fragment>,
                 <Fragment key="suffix">{suffix ? this.renderSuffix() : null}</Fragment>,
             ]
         );
 
-        const tabIndex = disabled ? null : 0;
+        /**
+         * 
+         * In disabled, searchable single-selection and display input, and searchable multi-selection
+         * make combobox not focusable by tab key
+         * 
+         * 在disabled,可搜索单选且显示input框,以及可搜索多选情况下
+         * 让combobox无法通过tab聚焦
+         */
+        const tabIndex = (disabled || (filterable && showInput) || (filterable && multiple)) ? -1 : 0;
         return (
+            /* eslint-disable-next-line jsx-a11y/aria-activedescendant-has-tabindex */
             <div
                 role="combobox"
                 aria-disabled={disabled}
                 aria-expanded={isOpen}
                 aria-controls={`${prefixcls}-${this.selectOptionListID}`}
                 aria-haspopup="listbox"
-                aria-label="select value"
+                aria-label={selections.size ? 'selected' : ''} // if there is a value, expect the narration to speak selected
                 aria-invalid={this.props['aria-invalid']}
                 aria-errormessage={this.props['aria-errormessage']}
                 aria-labelledby={this.props['aria-labelledby']}
@@ -1144,11 +1177,12 @@ class Select extends BaseComponent<SelectProps, SelectState> {
                 ref={ref => ((this.triggerRef as any).current = ref)}
                 onClick={e => this.foundation.handleClick(e)}
                 style={style}
-                id={id}
+                id={this.selectID}
                 tabIndex={tabIndex}
+                aria-activedescendant={focusIndex !== -1 ? `${this.selectID}-option-${focusIndex}`: ''}
                 onMouseEnter={this.onMouseEnter}
                 onMouseLeave={this.onMouseLeave}
-                // onFocus={e => this.foundation.handleTriggerFocus(e)}
+                onFocus={e => this.foundation.handleTriggerFocus(e)}
                 onBlur={e => this.foundation.handleTriggerBlur(e as any)}
                 onKeyPress={this.onKeyPress}
                 {...keyboardEventSet}
@@ -1193,6 +1227,7 @@ class Select extends BaseComponent<SelectProps, SelectState> {
                 position={position}
                 spacing={spacing}
                 stopPropagation={stopPropagation}
+                disableArrowKeyDown={true}
                 onVisibleChange={status => this.handlePopoverVisibleChange(status)}
             >
                 {selection}

+ 2 - 0
packages/semi-ui/select/option.tsx

@@ -88,6 +88,7 @@ class Option extends PureComponent<OptionProps> {
             prefixCls,
             renderOptionItem,
             inputValue,
+            id,
             ...rest
         } = this.props;
         const optionClassName = classNames(prefixCls, {
@@ -146,6 +147,7 @@ class Option extends PureComponent<OptionProps> {
                 }}
                 onMouseEnter={e => onMouseEnter && onMouseEnter(e)}
                 role="option"
+                id={id}
                 aria-selected={selected ? "true" : "false"}
                 aria-disabled={disabled ? "true" : "false"}
                 style={style}

+ 3 - 2
packages/semi-ui/tag/index.tsx

@@ -119,10 +119,11 @@ export default class Tag extends Component<TagProps, TagState> {
     }
 
     render() {
-        const { children, size, color, closable, visible, onClose, onClick, className, type, avatarSrc, avatarShape, ...attr } = this.props;
+        const { children, size, color, closable, visible, onClose, onClick, className, type, avatarSrc, avatarShape, tabIndex, ...attr } = this.props;
         const { visible: isVisible } = this.state;
         const clickable = onClick !== Tag.defaultProps.onClick || closable;
-        const a11yProps = { role: 'button', tabIndex: 0, onKeyDown: this.handleKeyDown };
+        // only when the Tag is clickable or closable, the value of tabIndex is allowed to be passed in. 
+        const a11yProps = { role: 'button', tabIndex: tabIndex | 0, onKeyDown: this.handleKeyDown };
         const baseProps = {
             ...attr,
             onClick,

+ 1 - 0
packages/semi-ui/tag/interface.ts

@@ -35,6 +35,7 @@ export interface TagProps {
     avatarShape?: AvatarShape;
     onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
     'aria-label'?: React.AriaAttributes['aria-label'];
+    tabIndex?: number; // use internal, when tag in taInput, we want to use left arrow and right arrow to control the tag focus, so the tabIndex need to be -1. 
 }
 
 export interface TagGroupProps {

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

@@ -75,6 +75,7 @@ export interface TooltipProps extends BaseProps {
     guardFocus?: boolean;
     returnFocusOnClose?: boolean;
     onEscKeyDown?: (e: React.KeyboardEvent) => void;
+    disableArrowKeyDown?: boolean; 
     wrapperId?: string;
     preventScroll?: boolean;
 }
@@ -162,6 +163,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         guardFocus: false,
         returnFocusOnClose: false,
         onEscKeyDown: noop,
+        disableArrowKeyDown: false,
     };
 
     eventManager: Event;
@@ -716,8 +718,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     ref.current = node;
                 }
             },
-            tabIndex: 0, // a11y keyboard
-            'data-popupid': id
+            tabIndex:  (children as React.ReactElement).props.tabIndex || 0 // a11y keyboard, in some condition select's tabindex need to -1 or 0 
         });
 
         // 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