Przeglądaj źródła

feat(a11y): add keyboard and focus events to tabs

feat(a11y): add keyboard and focus events to tabs

feat(a11y): add keyboard and focus events to tabs
linyan 3 lat temu
rodzic
commit
564c911829

+ 18 - 2
content/navigation/tabs/index-en-US.md

@@ -568,13 +568,29 @@ closable | whether user can close the tab **>=2.1.0** | boolean | false |
   - TabBar has a role of `tablist`
   - Tab in TabBar has a role of `tab`
   - TabPane has a role of `tabpanel`
-
-- aria-orientation: Indicates TabBar's orientation, can be `vertical` or `horizontal`. When tabPosition is `left`, aria-orientation will be `vertical`, when tabPosition is `top`, aria-orientation will be `horizontal`.
+- aria-orientation: Indicates TabBar's orientation, can be `vertical` or `horizontal`. When tabPosition is `left`,aria-orientation will be `vertical`, when tabPosition is `top`, aria-orientation will be `horizontal`.
 - aria-disabled: When TabPane is disabled, the related Tab's aria-disabled will be set to true.
 - aria-selected: Indicates whether the Tab is selected.
 - aria-controls: Indicates the TabPane controlled by the Tab
 - aria-labelledby: Indicates the element labels the TabPane
 
+### Keyboard and Focus
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/
+- Tabs can be given focus, except for disabled tabs
+- Keyboard users can use the `Tab` key to move the focus to the tab panel of the selected tab element
+- Use `left and right arrows` to toggle options when focus is on a tab element in a horizontal tab list
+- Use `up and down arrows` to toggle options when focus is on a tab element in a vertical tab list
+- When the focus is on an inactive tab element in the tab list, the `Space` or `Enter` keys can be used to activate the tab
+- When keyboard users want to focus directly on the last tab element in the tab list:
+  - Mac users: `fn` + `right arrow`
+  - Windows users: `End`
+- When keyboard users want to focus directly on the first tab element in the tab list:
+  - Mac users: `fn` + `left arrow`
+  - Windows users: `Home`
+- When a tab is allowed to be deleted:
+  - Mac users can use `fn` + `Delete` keys to delete tabs, Windows users use `Delete` keys
+  - After deletion, the focus is transferred to the next element of the deleted tab element; if the deleted element has no subsequent element, it is transferred to the previous element
+
 ## Design Token
 
 <DesignToken/>

+ 19 - 2
content/navigation/tabs/index.md

@@ -592,13 +592,30 @@ closable  | 允许关闭tab **>=2.1.0**| boolean | false |
   - TabBar 对应的 role 为 `tablist`
   - TabBar 中的 Tab 对应的 role 为 `tab`
   - TabPane 对应的 role 为 `tabpanel`
-
-- aria-orientation: 表明 TabBar 的方向,有 `vertical` 和 `horizontal` 两种。当传入 tabPosition 为 left 时,aria-orientation 会被设置为 `vertical`,tabPosition 为 top 时,设置为 `horizontal`
+- aria-orientation: 表明 TabBar 的方向,有 `vertical` 和 `horizontal` 两种。当传入 tabPosition 为 left 时, aria-orientation 会被设置为 `vertical`,tabPosition 为 top 时,设置为 `horizontal`
 - aria-disabled: 当 TabPane 设置为 disabled 时,对应 Tab 的 aria-disabled 会被设置为 true
 - aria-selected: 表明 Tab 是否被选中
 - aria-controls: 指向 Tab 标签所控制的 TabPane
 - aria-labelledby: 指向设置 TabPane 标签的元素
 
+### 键盘和焦点
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/
+- 选项卡可以被获取到焦点,但禁用的选项卡除外
+- 键盘用户可以使用 `Tab` 键,将焦点移动到已被选择的选项卡元素的选项卡面板上
+- 当焦点位于水平选项卡列表中的选项卡元素上时,使用 `左右箭头` 来切换选项
+- 当焦点位于垂直选项卡列表中的选项卡元素上时,使用 `上下箭头` 来切换选项
+- 当焦点位于选项卡列表中的未被激活的选项卡元素上时,可以使用 `Space` 或 `Enter` 键来激活该选项卡
+- 当键盘用户想要直接将焦点聚焦到选项卡列表中的最后一个选项卡元素时:
+    - Mac 用户:`fn` + `右箭头`
+    - Windows 用户:`End`
+- 当键盘用户想要直接将焦点聚焦到选项卡列表中的第一个选项卡元素时:
+    - Mac 用户:`fn` + `左箭头`
+    - Windows 用户:`Home`
+- 当选项卡允许被删除时:
+    - Mac 用户可以使用 `fn` + `Delete` 键删除选项卡,Windows 用户使用 `Delete` 键
+    - 删除后,焦点转移到被删除选项卡元素的后一个元素上;若被删除元素无后一个元素则转移到前一个元素上
+
+
 ## 设计变量
 
 <DesignToken/>

+ 89 - 1
packages/semi-foundation/tabs/foundation.ts

@@ -1,5 +1,5 @@
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
-import { noop } from 'lodash';
+import { get, noop } from 'lodash';
 
 export interface TabsAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     collectPane: () => void;
@@ -72,6 +72,94 @@ class TabsFoundation<P = Record<string, any>, S = Record<string, any>> extends B
     handleTabDelete(tabKey: string): void {
         this._adapter.notifyTabDelete(tabKey);
     }
+
+    handlePrevent = (event: any) => {
+        event.stopPropagation();
+        event.preventDefault();
+    }
+
+    handleKeyDown = (event: any, itemKey: string, closable: boolean) => {
+        const tabs = [...event.target.parentNode.childNodes].filter(item => {
+            return get(item, 'attributes.data-tabkey.value', '').includes('semiTab') && item.ariaDisabled!=="true";
+        });
+
+        switch (event.key) {
+            case "ArrowLeft":
+            case "ArrowRight":
+            case "ArrowUp":
+            case "ArrowDown":
+                this.determineOrientation(event, tabs);
+                break;
+            case "Delete":
+                this.handleDeleteKeyDown(event, tabs, itemKey, closable);
+                break;
+            case "Enter":
+            case " ":
+                this.handleTabClick(itemKey, event);
+                this.handlePrevent(event);
+                break;
+            case "Home":
+                tabs[0].focus(); // focus first tab
+                this.handlePrevent(event);
+                break;
+            case "End":
+                tabs[tabs.length - 1].focus(); // focus last tab
+                this.handlePrevent(event);
+                break;
+        }
+    }
+
+    determineOrientation(event: any, tabs: HTMLElement[]): void {
+        const { tabPosition } = this.getProps();
+        const isVertical = tabPosition === 'left';
+
+        if (isVertical) {
+            if (event.key ===  "ArrowUp" || event.key ===  "ArrowDown") {
+                this.switchTabOnArrowPress(event, tabs);
+                this.handlePrevent(event);
+            }
+        } else {
+            if (event.key ===  "ArrowLeft" || event.key === "ArrowRight") {
+                this.switchTabOnArrowPress(event, tabs);
+                this.handlePrevent(event);
+            }
+        }
+    }
+
+    handleDeleteKeyDown(event:any, tabs: HTMLElement[], itemKey: string, closable: boolean): void {
+        if (closable) {
+            this.handleTabDelete(itemKey);
+            const index = tabs.indexOf(event.target);
+            // Move focus to next element after deletion
+            // If the element is the last removable tab, focus to its previous tab
+            if (tabs.length !== 1 ){
+                tabs[index + 1 >= tabs.length ? index - 1 : index + 1].focus();   
+            }
+        }
+    }
+
+    switchTabOnArrowPress(event: any, tabs: HTMLElement[]): void {
+        const index = tabs.indexOf(event.target);
+
+        const direction = {
+            "ArrowLeft": -1,
+            "ArrowUp": -1,
+            "ArrowRight": 1,
+            "ArrowDown": 1,
+        };
+
+        if (direction[event.key]) {
+            if (index !== undefined) {
+                if (tabs[index + direction[event.key]]) {
+                    tabs[index+ direction[event.key]].focus();
+                } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+                    tabs[tabs.length - 1].focus(); // focus last tab
+                } else if (event.key ===  "ArrowRight" || event.key == "ArrowDown") {
+                    tabs[0].focus(); // focus first tab
+                }
+            }
+        }
+    }
 }
 
 export default TabsFoundation;

+ 30 - 1
packages/semi-foundation/tabs/tabs.scss

@@ -124,6 +124,11 @@ $module: #{$prefix}-tabs;
                     width: 0;
                     height: 0;
                 }
+
+                &:focus-visible {
+                    outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+                    outline-offset: $width-tabs-outline-offset;
+                }
             }
         }
         
@@ -164,6 +169,11 @@ $module: #{$prefix}-tabs;
                 &:hover {
                     border-bottom: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-hover;
                 }
+
+                &:focus-visible {
+                    outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+                    outline-offset: $width-tabs_bar_line-outline-offset;
+                }
                 
                 &:active {
                     border-bottom: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-active;
@@ -202,6 +212,11 @@ $module: #{$prefix}-tabs;
                     border-left: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-hover;
                     background-color: $color-tabs_tab_line_vertical-bg-hover;
                 }
+
+                &:focus-visible {
+                    outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+                    outline-offset: $width-tabs-outline-offset;
+                }
                 
                 &:active {
                     border-left: $width-tabs_bar_line_tab-border solid $color-tabs_tab_line_default-border-active;
@@ -327,6 +342,11 @@ $module: #{$prefix}-tabs;
             &:hover {
                 background: $color-tabs_tab_card-bg-hover;
             }
+
+            &:focus-visible {
+                outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+                outline-offset: $width-tabs-outline-offset;
+            }
             
             &:active {
                 background: $color-tabs_tab_card-bg-active;
@@ -365,6 +385,11 @@ $module: #{$prefix}-tabs;
                 border: none;
                 background-color: $color-tabs_tab_button-bg-hover;
             }
+
+            &:focus-visible {
+                outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+                outline-offset: $width-tabs-outline-offset;
+            }
             
             &:active {
                 background-color: $color-tabs_tab_button-bg-active;
@@ -393,13 +418,17 @@ $module: #{$prefix}-tabs;
         height: 100%;
         padding: $spacing-tabs_content_left-paddingY $spacing-tabs_content_left-paddingX;
     }
-    
+
     &-pane {
         width: 100%;
         overflow: hidden;
         // position: absolute;
         // flex-shrink: 0;
         // position: absolute;
+
+        &:focus-visible {
+            outline: $width-tabs-outline solid $color-tabs_tab-outline-focus;
+        }
     }
     
     &-pane-inactive,

+ 5 - 0
packages/semi-foundation/tabs/variables.scss

@@ -44,6 +44,8 @@ $color-tabs_tab-icon-hover: var(--semi-color-text-0); // 页签图标颜色 - 
 $color-tabs_tab-icon-active: var(--semi-color-text-0); // 页签图标颜色 - 按下
 $color-tabs_tab_selected-icon-default: var(--semi-color-primary); // 页签图标颜色 - 选中
 
+$color-tabs_tab-outline-focus: var(--semi-color-primary-light-active); // 页签轮廓 - 聚焦
+
 
 $font-tabs_tab-fontWeight: $font-weight-regular; // 页签文本字重 - 默认
 $font-tabs_tab_active-fontWeight: $font-weight-bold; // 页签文本字重 - 选中
@@ -54,6 +56,9 @@ $width-tabs_bar_line-border: $border-thickness-control; // 线条式页签底部
 $width-tabs_bar_line_tab-border: 2px; // 页签标示线宽度
 
 $width-tabs_bar_card-border: $border-thickness-control; // 卡片式页签底部分割线宽度
+$width-tabs-outline: 2px; // 聚焦轮廓宽度
+$width-tabs-outline-offset: -2px; // 聚焦轮廓偏移宽度
+$width-tabs_bar_line-outline-offset: -1px; // 线条式页签聚焦轮廓偏移宽度
 
 $height-tabs_bar_extra_large: 50px; // 大尺寸页签高度
 $font-tabs_bar_extra_large-lineHeight: $height-tabs_bar_extra_large; // 大尺寸页签文字行高

+ 7 - 0
packages/semi-ui/tabs/TabBar.tsx

@@ -88,6 +88,10 @@ class TabBar extends React.Component<TabBarProps, TabBarState> {
         }
     };
 
+    handleKeyDown = (event: React.KeyboardEvent, itemKey: string, closable: boolean) => {
+        this.props.handleKeyDown(event, itemKey, closable);
+    }
+
     renderTabItem = (panel: PlainTab): ReactNode => {
         const { size, type, deleteTabItem } = this.props;
         const panelIcon = panel.icon ? this.renderIcon(panel.icon) : null;
@@ -110,9 +114,12 @@ class TabBar extends React.Component<TabBarProps, TabBarState> {
             <div
                 role="tab"
                 id={`semiTab${key}`}
+                data-tabkey={`semiTab${key}`}
                 aria-controls={`semiTabPanel${key}`}
                 aria-disabled={panel.disabled ? 'true' : 'false'}
                 aria-selected={isSelected ? 'true' : 'false'}
+                tabIndex={isSelected ? 0 : -1}
+                onKeyDown={e => this.handleKeyDown(e, key, panel.closable)}
                 {...events}
                 className={className}
                 key={this._getItemKey(key)}

+ 1 - 0
packages/semi-ui/tabs/TabPane.tsx

@@ -96,6 +96,7 @@ class TabPane extends PureComponent<TabPaneProps> {
                 className={classNames}
                 style={style}
                 aria-hidden={active ? 'false' : 'true'}
+                tabIndex={0}
                 {...getDataAttr(restProps)}
             >
                 {motion ? (

+ 2 - 1
packages/semi-ui/tabs/index.tsx

@@ -266,7 +266,8 @@ class Tabs extends BaseComponent<TabsProps, TabsState> {
             tabBarExtraContent,
             tabPosition,
             type,
-            deleteTabItem: this.deleteTabItem
+            deleteTabItem: this.deleteTabItem,
+            handleKeyDown: this.foundation.handleKeyDown
         } as TabBarProps;
 
         const tabBar = renderTabBar ? renderTabBar(tabBarProps, TabBar) : <TabBar {...tabBarProps} />;

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

@@ -53,6 +53,7 @@ export interface TabBarProps {
     dropdownStyle?: CSSProperties;
     closable?: boolean;
     deleteTabItem?: (tabKey: string, event: MouseEvent<Element>) => void;
+    handleKeyDown?:  (event: React.KeyboardEvent, itemKey: string, closable: boolean) => void;
 }
 
 export interface TabPaneProps {