Forráskód Böngészése

feat(a11y): checkbox keyboard focus (#891)

YyumeiZhang 3 éve
szülő
commit
a1812c788d

+ 6 - 0
content/input/checkbox/index-en-US.md

@@ -442,6 +442,12 @@ import { CheckboxGroup, Checkbox, Row, Col } from '@douyinfe/semi-ui';
 - `aria-disabled` indicates the current disabled state, which is consistent with the value of the `disabled` prop
 - `aria-checked` indicates the current checked state
 
+### Keyboard and focus
+- Checkbox can be focused, keyboard users can use Tab and Shift + Tab to switch focus.
+- The Checkbox that gets the focus can switch the selected and unselected states through Space.
+- The click area of ​​Checkbox is larger than the box itself and contains the text behind the box; for checkboxes with auxiliary text, the auxiliary text is also included in the click area.
+- Disabled Checkbox is not focusable.
+
 ## Design Tokens
 <DesignToken/>
 

+ 6 - 0
content/input/checkbox/index.md

@@ -424,6 +424,12 @@ import { Checkbox, CheckboxGroup, Row, Col } from '@douyinfe/semi-ui';
 - `aria-disabled` 表示当前的禁用状态,与 `disabled` prop 的值保持一致
 - `aria-checked` 表示当前的选中状态
 
+### 键盘和焦点
+- Checkbox 可被获取焦点,键盘用户可以使用 Tab 及 Shift  + Tab 切换焦点。
+- 当前获取的焦点为 Checkbox 时,可以通过 Space 切换选中和未选状态。
+- Checkbox 的点击区域大于框本身,包含了框后的文案;带辅助文本的 checkbox,辅助文本也包含在点击区域内。
+- 禁用的 Checkbox 不可获取焦点。
+
 ## 设计变量
 <DesignToken/>
 

+ 39 - 0
cypress/integration/checkbox.spec.js

@@ -0,0 +1,39 @@
+describe('checkbox', () => {
+    it('checkbox tab test', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=checkbox--checkbox-group-demo&args=&viewMode=story');
+        cy.get('.semi-checkbox').eq(0).click();
+        cy.focused().tab();
+        cy.focused().type('{backspace}');
+        cy.get('.semi-checkbox').eq(1).get('.semi-checkbox-checked');
+        cy.focused().type('{backspace}');
+        cy.get('.semi-checkbox').eq(1).get('.semi-checkbox-unChecked');
+    });
+
+    it('checkbox disable', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=checkbox--checkbox-default&args=&viewMode=story');
+        cy.get('.semi-checkbox').eq(0).click();
+        cy.focused().tab();
+        cy.focused().tab();
+        cy.focused().tab();
+        cy.get('.semi-checkbox-inner-display').eq(4).get('.semi-checkbox-focus');
+    });
+
+    it('checkbox card', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=checkbox--checkbox-group-card-style&args=&viewMode=story');
+        cy.get('.semi-checkbox').eq(0).click();
+        cy.focused().tab();
+        cy.get('.semi-checkbox').eq(1).get('.semi-checkbox-focus');
+        cy.get('.semi-checkbox-focus').eq(0).type('{backspace}');
+        cy.get('.semi-checkbox-inner-display').eq(1).get('.semi-icon-checkbox_tick');
+    });
+
+    it('checkbox pureCard', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=checkbox--checkbox-group-pure-card-style&args=&viewMode=story');
+        cy.get('.semi-checkbox').eq(0).click();
+        cy.focused().tab();
+        cy.get('.semi-checkbox').eq(1).get('.semi-checkbox-focus');
+        cy.get('.semi-checkbox-focus').eq(0).type('{backspace}');
+        cy.get('.semi-checkbox-inner-display').eq(1).get('.semi-icon-checkbox_tick');
+    });
+
+});

+ 14 - 1
packages/semi-foundation/checkbox/checkbox.scss

@@ -103,7 +103,12 @@ $module: #{$prefix}-checkbox;
         }
 
         &-pureCardType {
-            display: none;
+            // Reasons to use opacity:0 & width: 0 instead of display: none
+            // The a11y keyboard focus event of the checkbox depends on the implementation of the input focus/blur event
+            // input focus/blur cannot take effect when display: none
+            opacity: 0;
+            width: 0;
+            margin-right: 0 !important;
         }
     }
 
@@ -347,6 +352,14 @@ $module: #{$prefix}-checkbox;
         color: $color-checkbox_extra-text-default;
         margin-top: $spacing-checkbox_extra-marginTop;
     }
+
+    &-focus {
+        outline: $width-checkbox-outline solid $color-checkbox_primary-outline-focus;
+        
+        &-border {
+            box-shadow: inset 0 0 0 $size-checkbox_inner-shadow $color-checkbox_default-border-hover;
+        }
+    }
 }
 
 .#{$module}Group {

+ 29 - 0
packages/semi-foundation/checkbox/checkboxFoundation.ts

@@ -23,6 +23,8 @@ export interface CheckboxAdapter<P = Record<string, any>, S = Record<string, any
     notifyChange: (event: BasicCheckboxEvent) => void;
     setAddonId: () => void;
     setExtraId: () => void;
+    setFocusVisible: (focusVisible: boolean) => void;
+    focusCheckboxEntity: () => void;
 }
 
 class CheckboxFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<CheckboxAdapter<P, S>, P, S> {
@@ -31,6 +33,8 @@ class CheckboxFoundation<P = Record<string, any>, S = Record<string, any>> exten
         super({ ...adapter });
     }
 
+    clickState = false;
+
     init() {
         const { children, extra, extraId, addonId } = this.getProps();
         if (children && !addonId) {
@@ -77,6 +81,12 @@ class CheckboxFoundation<P = Record<string, any>, S = Record<string, any>> exten
             return;
         }
 
+        if (e?.type === 'click') {
+            this.clickState = true;
+        }
+
+        this._adapter.focusCheckboxEntity();
+
         const isInGroup = this._adapter.getIsInGroup();
 
         if (isInGroup) {
@@ -118,6 +128,25 @@ class CheckboxFoundation<P = Record<string, any>, S = Record<string, any>> exten
         this._adapter.setNativeControlChecked(checked);
     }
 
+    handleFocusVisible = (event: any) => {
+        const { target } = event;
+        try {
+            if (this.clickState) {
+                this.clickState = false;
+                return;
+            } 
+            if (target.matches(':focus-visible')) {
+                this._adapter.setFocusVisible(true);
+            }
+        } catch (error){
+            console.warn('The current browser does not support the focus-visible');
+        }
+    }
+
+    handleBlur = () => {
+        this._adapter.setFocusVisible(false);
+    }
+
     // eslint-disable-next-line @typescript-eslint/no-empty-function
     destroy() {}
 }

+ 2 - 0
packages/semi-foundation/checkbox/variables.scss

@@ -36,12 +36,14 @@ $color-checkbox_disabled-bg-default: var(--semi-color-disabled-fill); // 选框
 $color-checkbox_disabled-border-default: var(--semi-color-border); // 选框禁用态描边颜色 - 默认
 $color-checkbox_checked-bg-disabled: var(--semi-color-primary-disabled); // 选框选中 + 禁用态背景颜色
 $color-checkbox_checked-icon-disabled: var(--semi-color-white); // 选框禁用态对勾颜色
+$color-checkbox_primary-outline-focus: var(--semi-color-primary-light-active); // 复选框轮廓-聚焦颜色
 
 $size-checkbox_inner-shadow: $border-thickness-control; // 选框内描边宽度
 $width-checkbox_inner: $width-icon-medium; // 选框对勾 icon 宽度
 $height-checkbox_inner: 20px; // 选框对勾 icon 高度
 $width-checkbox_cardType_checked-border: 1px; // 卡片类型复选框的边框宽度
 $width-checkbox_cardType_checked_disabled-border: 1px; // 卡片类型复选框选中且禁用的边框宽度
+$width-checkbox-outline: 2px; // 复选框轮廓宽度
 
 $radius-checkbox_cardType: 3px; // 卡片类型复选框的圆角大小
 $radius-checkbox_inner: var(--semi-border-radius-extra-small); // 选框圆角

+ 26 - 5
packages/semi-ui/checkbox/checkbox.tsx

@@ -36,6 +36,7 @@ interface CheckboxState {
     checked: boolean;
     addonId?: string;
     extraId?: string;
+    focusVisible?: boolean;
 }
 class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
     static contextType = Context;
@@ -99,7 +100,13 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             },
             setExtraId: () => {
                 this.setState({ extraId: getUuidShort({ prefix: 'extra' }) });
-            }
+            },
+            setFocusVisible: (focusVisible: boolean): void => {
+                this.setState({ focusVisible });
+            },
+            focusCheckboxEntity: () => {
+                this.focus();
+            },
         };
     }
 
@@ -115,6 +122,7 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             checked: props.checked || props.defaultChecked || checked,
             addonId: props.addonId,
             extraId: props.extraId,
+            focusVisible: false
         };
 
         this.checkboxEntity = null;
@@ -147,6 +155,14 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
 
     handleEnterPress = (e: React.KeyboardEvent<HTMLSpanElement>) => this.foundation.handleEnterPress(e);
 
+    handleFocusVisible = (event: React.FocusEvent) => {
+        this.foundation.handleFocusVisible(event);
+    }
+
+    handleBlur = (event: React.FocusEvent) => {
+        this.foundation.handleBlur();
+    }
+
     render() {
         const {
             disabled,
@@ -163,7 +179,7 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             tabIndex,
             id
         } = this.props;
-        const { checked, addonId, extraId } = this.state;
+        const { checked, addonId, extraId, focusVisible } = this.state;
         const props: Record<string, any> = {
             checked,
             disabled,
@@ -186,6 +202,8 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
 
         const prefix = prefixCls || css.PREFIX;
 
+        const focusOuter = props.isCardType || props.isPureCardType;
+
         const wrapper = classnames(prefix, {
             [`${prefix}-disabled`]: props.disabled,
             [`${prefix}-indeterminate`]: indeterminate,
@@ -197,6 +215,7 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             [`${prefix}-cardType_checked`]: props.isCardType && props.checked && !props.disabled,
             [`${prefix}-cardType_checked_disabled`]: props.isCardType && props.checked && props.disabled,
             [className]: Boolean(className),
+            [`${prefix}-focus`]: focusVisible && focusOuter,
         });
 
         const extraCls = classnames(`${prefix}-extra`, {
@@ -211,7 +230,6 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
         );
         return (
             // label is better than span, however span is here which is to solve gitlab issue #364
-            // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
             <span
                 role={role}
                 tabIndex={tabIndex}
@@ -227,12 +245,15 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
                 <CheckboxInner
                     {...this.props}
                     {...props}
-                    addonId={children && this.addonId}
-                    extraId={extra && this.extraId}
+                    addonId={children && addonId}
+                    extraId={extra && extraId}
                     isPureCardType={props.isPureCardType}
                     ref={ref => {
                         this.checkboxEntity = ref;
                     }}
+                    focusInner={focusVisible && !focusOuter}
+                    onInputFocus={this.handleFocusVisible}
+                    onInputBlur={this.handleBlur}
                 />
                 {
                     props.isCardType ?

+ 11 - 1
packages/semi-ui/checkbox/checkboxInner.tsx

@@ -23,6 +23,9 @@ export interface CheckboxInnerProps {
     addonId?: string;
     extraId?: string;
     'aria-label'?: React.AriaAttributes['aria-label'];
+    focusInner?: boolean;
+    onInputFocus?: (e: any) => void;
+    onInputBlur?: (e: any) => void;
 }
 
 class CheckboxInner extends PureComponent<CheckboxInnerProps> {
@@ -43,6 +46,9 @@ class CheckboxInner extends PureComponent<CheckboxInnerProps> {
         isPureCardType: PropTypes.bool,
         addonId: PropTypes.string,
         extraId: PropTypes.string,
+        focusInner: PropTypes.bool,
+        onInputFocus: PropTypes.func,
+        onInputBlur: PropTypes.func,
     };
 
     static defaultProps = {
@@ -59,7 +65,7 @@ class CheckboxInner extends PureComponent<CheckboxInnerProps> {
     }
 
     render() {
-        const { indeterminate, checked, disabled, prefixCls, name, isPureCardType, addonId, extraId } = this.props;
+        const { indeterminate, checked, disabled, prefixCls, name, isPureCardType, addonId, extraId, focusInner, onInputFocus, onInputBlur } = this.props;
         const prefix = prefixCls || css.PREFIX;
 
         const wrapper = classnames(
@@ -73,6 +79,8 @@ class CheckboxInner extends PureComponent<CheckboxInnerProps> {
 
         const inner = classnames({
             [`${prefix}-inner-display`]: true,
+            [`${prefix}-focus`]: focusInner,
+            [`${prefix}-focus-border`]:  focusInner && !checked,
         });
 
         const icon = checked ? (
@@ -95,6 +103,8 @@ class CheckboxInner extends PureComponent<CheckboxInnerProps> {
             onChange: noop,
             checked: checked,
             disabled: disabled,
+            onFocus: onInputFocus,
+            onBlur: onInputBlur,
         };
         
         name && (inputProps['name'] = name);