Răsfoiți Sursa

feat(a11y): Radio a11y keyboard #205 (#901)

YyumeiZhang 3 ani în urmă
părinte
comite
6b891690ec

+ 44 - 18
content/input/radio/index-en-US.md

@@ -28,7 +28,7 @@ import React from 'react';
 import { Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <Radio aria-label="Radio demo">Radio</Radio>
+    <Radio aria-label="Radio demo" name="demo-radio">Radio</Radio>
 );
 
 ```
@@ -45,7 +45,7 @@ import { Radio } from '@douyinfe/semi-ui';
 
 
 () => (
-    <Radio extra="Semi Design is a design system developed and maintained by IES Front-end Team and UED Team" aria-label="Radio demo">
+    <Radio extra="Semi Design is a design system developed and maintained by IES Front-end Team and UED Team" aria-label="Radio demo" name="demo-radio-extra">
         Semi Design
     </Radio>
 );
@@ -75,11 +75,11 @@ class App extends React.Component {
     render() {
         return (
             <div>
-                <Radio defaultChecked={false} disabled={this.state.disabled} aria-label="Radio demo">
+                <Radio defaultChecked={false} disabled={this.state.disabled} aria-label="Radio demo" name="demo-radio-disabled">
                     Disabled
                 </Radio>
                 <br />
-                <Radio defaultChecked disabled={this.state.disabled} aria-label="Radio demo">
+                <Radio defaultChecked disabled={this.state.disabled} aria-label="Radio demo" name="demo-radio-defaultChecked-disabled">
                     Disabled
                 </Radio>
                 <div style={{ marginTop: 20 }}>
@@ -120,7 +120,7 @@ class App extends React.Component {
     render() {
         return (
             <div>
-                <Radio checked={this.state.checked} mode="advanced" onChange={this.onChange} aria-label="Radio demo">
+                <Radio checked={this.state.checked} mode="advanced" onChange={this.onChange} aria-label="Radio demo" name="demo-radio-advanced">
                     Click Again to Uncheck
                 </Radio>
             </div>
@@ -154,7 +154,7 @@ class App extends React.Component {
 
     render() {
         return (
-            <RadioGroup onChange={this.onChange} value={this.state.value} aria-label="RadioGroup demo">
+            <RadioGroup onChange={this.onChange} value={this.state.value} aria-label="RadioGroup demo" name="demo-radio-group">
                 <Radio value={1}>A</Radio>
                 <Radio value={2}>B</Radio>
                 <Radio value={3}>C</Radio>
@@ -165,6 +165,24 @@ class App extends React.Component {
 }
 ```
 
+### vertical arrangement
+
+The radio elements in the group can be arranged horizontally or vertically by setting the `direction` property to the RadioGroup
+
+```jsx live=true
+import React from 'react';
+import { RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+() => (
+    <RadioGroup direction="vertical" aria-label="RadioGroup demo" name="demo-radio-group-vertical">
+        <Radio value={1}>A</Radio>
+        <Radio value={2}>B</Radio>
+        <Radio value={3}>C</Radio>
+        <Radio value={4}>D</Radio>
+    </RadioGroup>
+);
+```
+
 ### Button Style
 
 version: >=1.26.0
@@ -211,17 +229,17 @@ class App extends React.Component {
     render() {
         return (
             <Space vertical spacing="loose" align="start">
-                <RadioGroup type="button" buttonSize="small" onChange={this.onChange1} value={this.state.value1} aria-label="RadioGroup demo">
+                <RadioGroup type="button" buttonSize="small" onChange={this.onChange1} value={this.state.value1} aria-label="RadioGroup demo" name="demo-radio-small">
                     <Radio value={1}>Instant push</Radio>
                     <Radio value={2}>Timed push</Radio>
                     <Radio value={3}>Dynamic push</Radio>
                 </RadioGroup>
-                <RadioGroup type="button" buttonSize="middle" onChange={this.onChange2} value={this.state.value2} aria-label="RadioGroup demo">
+                <RadioGroup type="button" buttonSize="middle" onChange={this.onChange2} value={this.state.value2} aria-label="RadioGroup demo" name="demo-radio-middle">
                     <Radio value={1}>Instant push</Radio>
                     <Radio value={2}>Timed push</Radio>
                     <Radio value={3}>Dynamic push</Radio>
                 </RadioGroup>
-                <RadioGroup type="button" buttonSize="large" onChange={this.onChange3} value={this.state.value3} aria-label="RadioGroup demo">
+                <RadioGroup type="button" buttonSize="large" onChange={this.onChange3} value={this.state.value3} aria-label="RadioGroup demo" name="demo-radio-large">
                     <Radio value={1}>Instant push</Radio>
                     <Radio value={2}>Timed push</Radio>
                     <Radio value={3}>Dynamic push</Radio>
@@ -243,7 +261,7 @@ import React from 'react';
 import { RadioGroup, Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <RadioGroup type='card' defaultValue={1} direction='vertical' aria-label="RadioGroup demo">
+    <RadioGroup type='card' defaultValue={1} direction='vertical' aria-label="RadioGroup demo" name="demo-radio-group-card">
         <Radio value={1} extra='Radio description' style={{width:280}}>
             Radio Title
         </Radio>
@@ -269,7 +287,7 @@ import React from 'react';
 import { RadioGroup, Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <RadioGroup type='pureCard' defaultValue={1} direction='vertical' aria-label="RadioGroup demo">
+    <RadioGroup type='pureCard' defaultValue={1} direction='vertical' aria-label="RadioGroup demo" name="demo-radio-group-pureCard">
         <Radio value={1} extra='Radio description' style={{width:280}}>
             Radio Title
         </Radio>
@@ -338,13 +356,13 @@ class App extends React.Component {
     render() {
         return (
             <div>
-                <RadioGroup options={this.plainOptions} onChange={this.onChange1} value={this.state.value1} aria-label="RadioGroup demo" />
+                <RadioGroup options={this.plainOptions} onChange={this.onChange1} value={this.state.value1} aria-label="RadioGroup demo" name="demo-radio-group-1"/>
                 <br />
                 <br />
-                <RadioGroup options={this.optionsWithDisabled} onChange={this.onChange3} value={this.state.value3} aria-label="RadioGroup demo" />
+                <RadioGroup options={this.optionsWithDisabled} onChange={this.onChange2} value={this.state.value2} aria-label="RadioGroup demo" name="demo-radio-group-2"/>
                 <br />
                 <br />
-                <RadioGroup options={this.options} onChange={this.onChange2} value={this.state.value2} aria-label="RadioGroup demo" />
+                <RadioGroup options={this.options} onChange={this.onChange3} value={this.state.value3}aria-label="RadioGroup demo" name="demo-radio-group-3"/>
             </div>
         );
     }
@@ -361,6 +379,7 @@ class App extends React.Component {
 | addonId | id of addon node, aria-labelledby refers to this id, if not set, it will generate an id randomly  **provided after v2.11.0**                                 | string            |       |
 | addonStyle | inline style of content wrapper<br/>**provided after v1.16.0** | object |  |
 | aria-label      | Label of Radio                                                            | string           | -  |
+| name         | The `name` attribute passed to `input[type="radio"]` in the Radio component, Radios with the same `name` belong to the same RadioGroup,The `name` attribute can refer to [MDN Radio](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input/radio#value)   | string         | -  |
 | autoFocus | Automatically focus the form control when the page is loaded | boolean | false |
 | checked | Specify whether it is currently selected | boolean | false |
 | className | Class name | string |  |
@@ -402,16 +421,23 @@ class App extends React.Component {
 
 ## Accessibility
 
-### Keyboard and Focus
-
-- Card type and button type Radio group can be selected by arrow switch
-
 ### ARIA
 
 - `aria-label`: used to explain the role of Radio or RadioGroup
 - `aria-labelledby` points to the addon node, used to explain the content of Radio
 - `aria-describedby` points to the extra node, which is used to explain the content of Radio
 
+### Keyboard and focus
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/radiobutton/
+
+- RadioGroup can be focused, the initial focus acquisition rules are as follows:
+  - When there is no selected item in the RadioGroup, the initial focus is on the first Radio item;
+  - When there are selected items in the RadioGroup, the initial focus is on the selected Radio item.
+- For radios belonging to the same radiogroup:
+  - You can use `Right arrow` or `Down arrow` to move the focus to the next Radio item, uncheck the previously focused Radio item, and select the currently focused Radio item;
+  - You can Use `Left Arrow` or `Up Arrow` to move the focus to the previous Radio item, at the same time uncheck the previously focused Radio item, and select the currently focused Radio item.
+- If there is no item selected in the RadioGroup, you can use the `Space` key to select the initial focus.
+
 <!-- ## Related Material
 
 ```material

+ 31 - 19
content/input/radio/index.md

@@ -25,7 +25,7 @@ import React from 'react';
 import { Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <Radio aria-label="单选示例">Radio</Radio>
+    <Radio aria-label="单选示例" name="demo-radio">Radio</Radio>
 );
 ```
 
@@ -40,7 +40,7 @@ import React from 'react';
 import { Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <Radio extra="Semi Design 是由互娱社区前端团队与 UED 团队共同设计开发并维护的设计系统" aria-label="单选示例">
+    <Radio extra="Semi Design 是由互娱社区前端团队与 UED 团队共同设计开发并维护的设计系统" aria-label="单选示例" name="demo-radio-extra">
         Semi Design
     </Radio>
 );
@@ -61,11 +61,11 @@ import { Radio, Button } from '@douyinfe/semi-ui';
     };
     return (
         <div>
-            <Radio defaultChecked={false} disabled={disabled} aria-label="单选示例">
+            <Radio defaultChecked={false} disabled={disabled} aria-label="单选示例" name="demo-radio-disabled">
                 Disabled
             </Radio>
             <br />
-            <Radio defaultChecked disabled={disabled} aria-label="单选示例">
+            <Radio defaultChecked disabled={disabled} aria-label="单选示例" name="demo-radio-defaultChecked-disabled">
                 Disabled
             </Radio>
             <div style={{ marginTop: 20 }}>
@@ -98,6 +98,7 @@ import { Radio } from '@douyinfe/semi-ui';
             mode="advanced"
             onChange={toggle}
             aria-label="单选示例"
+            name="demo-radio-advanced"
         >
             允许取消选择
         </Radio>
@@ -120,7 +121,7 @@ import { RadioGroup, Radio } from '@douyinfe/semi-ui';
         setValue(e.target.value);
     }; 
     return (
-        <RadioGroup onChange={onChange} value={value} aria-label="单选组合示例">
+        <RadioGroup onChange={onChange} value={value} aria-label="单选组合示例" name="demo-radio-group">
             <Radio value={1}>A</Radio>
             <Radio value={2}>B</Radio>
             <Radio value={3}>C</Radio>
@@ -139,7 +140,7 @@ import React from 'react';
 import { RadioGroup, Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <RadioGroup direction="vertical" aria-label="单选组合示例">
+    <RadioGroup direction="vertical" aria-label="单选组合示例" name="demo-radio-group-vertical">
         <Radio value={1}>A</Radio>
         <Radio value={2}>B</Radio>
         <Radio value={3}>C</Radio>
@@ -163,17 +164,17 @@ import { RadioGroup, Radio, Space } from '@douyinfe/semi-ui';
 () => {
     return (
         <Space vertical spacing='loose' align='start'>
-            <RadioGroup type='button' buttonSize='small' defaultValue={1} aria-label="单选组合示例">
+            <RadioGroup type='button' buttonSize='small' defaultValue={1} aria-label="单选组合示例" name="demo-radio-small">
                 <Radio value={1}>即时推送</Radio>
                 <Radio value={2}>定时推送</Radio>
                 <Radio value={3}>动态推送</Radio>
             </RadioGroup>
-            <RadioGroup type='button' buttonSize='middle' defaultValue={1} aria-label="单选组合示例">
+            <RadioGroup type='button' buttonSize='middle' defaultValue={1} aria-label="单选组合示例" name="demo-radio-middle">
                 <Radio value={1}>即时推送</Radio>
                 <Radio value={2}>定时推送</Radio>
                 <Radio value={3}>动态推送</Radio>
             </RadioGroup>
-            <RadioGroup type='button' buttonSize='large' defaultValue={1} aria-label="单选组合示例">
+            <RadioGroup type='button' buttonSize='large' defaultValue={1} aria-label="单选组合示例" name="demo-radio-large">
                 <Radio value={1}>即时推送</Radio>
                 <Radio value={2}>定时推送</Radio>
                 <Radio value={3}>动态推送</Radio>
@@ -194,7 +195,7 @@ import React from 'react';
 import { RadioGroup, Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <RadioGroup type='card' defaultValue={2} direction='vertical' aria-label="单选组合示例">
+    <RadioGroup type='card' defaultValue={2} direction='vertical' aria-label="单选组合示例" name="demo-radio-group-card">
         <Radio value={1} disabled extra='Semi Design 是由互娱社区前端团队与 UED 团队共同设计开发并维护的设计系统' style={{width:280}}>
             单选框标题
         </Radio>
@@ -218,7 +219,7 @@ import React from 'react';
 import { RadioGroup, Radio } from '@douyinfe/semi-ui';
 
 () => (
-    <RadioGroup type='pureCard' defaultValue={2} direction='vertical' aria-label="单选组合示例">
+    <RadioGroup type='pureCard' defaultValue={2} direction='vertical' aria-label="单选组合示例" name="demo-radio-group-pureCard">
         <Radio value={1} disabled extra='Semi Design 是由互娱社区前端团队与 UED 团队共同设计开发并维护的设计系统' style={{width:280}}>
             单选框标题
         </Radio>
@@ -292,18 +293,21 @@ class App extends React.Component {
                     onChange={this.onChange1}
                     value={this.state.value1}
                     aria-label="单选组合示例"
+                    name="demo-radio-group-1"
                 />
                 <RadioGroup
                     options={this.optionsWithDisabled}
-                    onChange={this.onChange3}
-                    value={this.state.value3}
+                    onChange={this.onChange2}
+                    value={this.state.value2}
                     aria-label="单选组合示例"
+                    name="demo-radio-group-2"
                 />
                 <RadioGroup
                     options={this.options}
-                    onChange={this.onChange2}
-                    value={this.state.value2}
+                    onChange={this.onChange3}
+                    value={this.state.value3}
                     aria-label="单选组合示例"
+                    name="demo-radio-group-3"
                 />
             </Space>
         );
@@ -321,6 +325,7 @@ class App extends React.Component {
 | addonId | addon 节点 id,aria-labelledby 指向这个 id,若无设置会随机生成一个 id  **v2.11.0 后提供**                                 | string            |       |
 | addonStyle     | 包裹内容容器的内联样式  **v1.16.0 后提供**                                 | CSSProperties     |       |
 | aria-label      | Radio 的 label                                                            | string           | -  |
+| name         | Radio组件中`input[type="radio"]`的`name`属性,具有相同`name`的Radio属于同一个RadioGroup,`name`属性可参考[MDN Radio](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/Input/radio#%E5%80%BC)                              | string         | -  |
 | autoFocus      | 自动获取焦点                                                            | boolean           | false  |
 | checked        | 指定当前是否选中                                                         | boolean           | false  |
 | className      | 样式类名                                                                | string            |        |
@@ -365,16 +370,23 @@ class App extends React.Component {
 
 ## Accessibility
 
-### 键盘和焦点
-
-- 卡片式、按钮式 Radio 组可以通过箭头切换选中
-
 ### ARIA
 
 - `aria-label`:用于解释 Radio 或 RadioGroup 的作用
 - `aria-labelledby` 默认指向 addon 节点,用于解释 Radio 的内容
 - `aria-describedby` 默认指向 extra 节点,用于补充解释 Radio 的内容
 
+### 键盘和焦点
+WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/radiobutton/
+
+- RadioGroup 可以被获取焦点,初始焦点设置:
+  - 当 RadioGroup 中没有被选择项时,初始焦点为第一个 Radio 项上;
+  - 当 RadioGroup 中有选中项时,初始焦点为选中的 Radio 项上。
+- 在同一个 radiogroup 内
+  - 可以通过 `右箭头` 或 `下箭头` 将焦点移动到下一个 Radio 项上,同时取消先前的 Radio 项的选中状态,并选中当前聚焦的 Radio 项;
+  - 可以通过 `左箭头` 或 `上箭头` 将焦点移动到上一个 Radio 项上,同时取消先前的 Radio 项的选中状态,并选中当前聚焦的 Radio 项。
+- 若 RadioGroup 中没有选中项,可以 `Space` 键选中初始焦点。
+
 <!-- ## 相关物料
 ```material
 123

+ 38 - 0
cypress/integration/radio.spec.js

@@ -0,0 +1,38 @@
+describe('radio', () => {
+    it('radio with extra', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=radio--radio-with-extra&args=&viewMode=story');
+        cy.get('body').tab();
+        cy.get('.semi-radio').eq(0).click();
+    });
+
+    it('radio extra click', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=radio--radio-with-extra&args=&viewMode=story');
+        cy.get('.semi-radio').eq(0).click();
+        cy.wait(100);
+        cy.focused().tab();
+        cy.get('input').eq(1).type('{backspace}');
+        cy.get('.semi-radio').eq(1).get('.semi-radio-inner-checked');
+    });
+
+    it('radio type button', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=radio--radio-group-button-style&args=&viewMode=story');
+        cy.get('.semi-radio-buttonRadioGroup-small').eq(0).click();
+        cy.focused().type('{downArrow}');
+        cy.wait(100);
+        cy.get('.semi-radio-buttonRadioGroup-small').eq(1).get('.semi-radio-inner-checked');
+    });
+
+    it('radio mode advance', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=radio--radio-with-advanced-mode&args=&viewMode=story');
+        cy.get('.semi-radio').click();
+        cy.focused().type('{backspace}');
+        cy.get('svg').should('not.exist');
+    });
+
+    it('radio group advance', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=radio--radio-group-with-advanced-mode&args=&viewMode=story');
+        cy.get('.semi-radio').eq(0).click();
+        cy.focused().type('{backspace}');
+        cy.get('.semi-radio').eq(0).get('.semi-radio-inner-checked').click();
+    });
+});

+ 8 - 0
packages/semi-foundation/radio/radio.scss

@@ -379,6 +379,14 @@ $inner-width: $width-icon-medium;
         padding-left: $spacing-radio_extra-paddingLeft;
         box-sizing: border-box;
     }
+
+    &-focus {
+        outline: $width-radio-outline solid $color-radio_primary-outline-focus;
+
+        &-border {
+            border: solid $width-radio_hover-border $color-radio_default-border-hover;
+        }
+    }
 }
 
 .#{$module}Group {

+ 16 - 0
packages/semi-foundation/radio/radioFoundation.ts

@@ -4,6 +4,7 @@ export interface RadioAdapter extends DefaultAdapter {
     setHover: (hover: boolean) => void;
     setAddonId: () => void;
     setExtraId: () => void;
+    setFocusVisible: (focusVisible: boolean) => void;
 }
 export default class RadioFoundation extends BaseFoundation<RadioAdapter> {
     init() {
@@ -18,4 +19,19 @@ export default class RadioFoundation extends BaseFoundation<RadioAdapter> {
     setHover(hover: boolean) {
         this._adapter.setHover(hover);
     }
+
+    handleFocusVisible = (event: any) => {
+        const { target } = event;
+        try {
+            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);
+    }
 }

+ 4 - 0
packages/semi-foundation/radio/variables.scss

@@ -48,6 +48,8 @@ $color-radio_card-bg-hover: var(--semi-color-white); // 单选圆点颜色
 $color-radio_card-bg-active: var(--semi-color-white); // 单选圆点颜色 - 按下态
 $color-radio_card-bg-default: var(--semi-color-white); // 单选圆点颜色 - 悬浮态
 
+$color-radio_primary-outline-focus: var(--semi-color-primary-light-active); // 轮廓颜色 - 按键聚焦
+
 $radius-radio_cardRadioGroup: var(--semi-border-radius-small); // 卡片式单选圆角大小
 $radius-radio_addon_buttonRadio: var(--semi-border-radius-small); // 按钮式单选圆点圆角大小
 $radius-radio_buttonRadio: var(--semi-border-radius-small); // 按钮式单选圆角大小
@@ -58,6 +60,8 @@ $width-radio_hover-border: $border-thickness-control; // 描边宽度 - 悬浮
 $width-radio_disabled-border: $border-thickness-control; // 描边宽度 - 禁用态
 $width-radio_innder-border: $border-thickness-control; // 描边宽度 - 禁用态
 
+$width-radio-outline: 2px; // 单选框轮廓宽度
+
 $height-radio_inner_min: 20px; // 单选按钮高度
 $width-radio_inner: $width-icon-medium; // 单选按钮宽度
 $spacing-radio_addon-paddingLeft: $spacing-tight; //单选标题到单选按钮左侧边距

+ 9 - 6
packages/semi-ui/radio/_story/radio.stories.js

@@ -34,13 +34,14 @@ _Radio.story = {
 export const RadioWithExtra = () => {
   return (
     <>
-      <Radio value="1" extra="这是辅助的文本,同厂辅助文本会更长一些,甚至还可能换行">
+      <Radio value="1" extra="这是辅助的文本,同厂辅助文本会更长一些,甚至还可能换行" name="demo-radio-1">
         示例文本
       </Radio>
       <Radio
         style={{ width: 200 }}
         value="1"
         extra="这是辅助的文本,同厂辅助文本会更长一些,甚至还可能换行"
+        name="demo-radio-2"
       >
         示例文本
       </Radio>
@@ -227,13 +228,13 @@ export const _RadioGroup = () => {
   return (
     <div>
       value=1
-      <RadioGroup name="pie" value="1" onChange={onChange}>
+      <RadioGroup name="pie1" value="1" onChange={onChange}>
         <Radio value="1">111</Radio>
         <Radio value="2">222</Radio>
       </RadioGroup>
       <br />
       defaultValue=1
-      <RadioGroup name="pie" defaultValue="1" onChange={onChange}>
+      <RadioGroup name="pie2" defaultValue="1" onChange={onChange}>
         <Radio value="1">111</Radio>
         <Radio value="2">222</Radio>
       </RadioGroup>
@@ -316,6 +317,8 @@ const RadioWithAdvancedMode = () => {
         onChange={e => {
           console.log(e);
           setChecked(e.target.checked);
+          e.stopPropagation();
+          e.preventDefault();
         }}
       >
         111
@@ -434,19 +437,19 @@ export const RadioGroupButtonStyle = () => {
     };
     return (
       <Space vertical spacing="loose" align="start">
-        <RadioGroup type="button" buttonSize="small" onChange={onChange1} value={value1}>
+        <RadioGroup type="button" buttonSize="small" onChange={onChange1} value={value1} name="demo-radio-button-1">
           <Radio value={1}>semi</Radio>
           <Radio value={2}>pipixia</Radio>
           <Radio value={3}>hotsoon</Radio>
           <Radio value={4}>toutiao</Radio>
         </RadioGroup>
-        <RadioGroup type="button" buttonSize="middle" onChange={onChange2} value={value2}>
+        <RadioGroup type="button" buttonSize="middle" onChange={onChange2} value={value2} name="demo-radio-button-2">
           <Radio value={1}>semi</Radio>
           <Radio value={2}>pipixia</Radio>
           <Radio value={3}>hotsoon</Radio>
           <Radio value={4}>toutiao</Radio>
         </RadioGroup>
-        <RadioGroup type="button" buttonSize="large" onChange={onChange3} value={value3}>
+        <RadioGroup type="button" buttonSize="large" onChange={onChange3} value={value3} name="demo-radio-button-3">
           <Radio value={1}>semi</Radio>
           <Radio value={2}>pipixia</Radio>
           <Radio value={3}>hotsoon</Radio>

+ 27 - 5
packages/semi-ui/radio/radio.tsx

@@ -43,12 +43,14 @@ export type RadioProps = {
     'aria-label'?: React.AriaAttributes['aria-label'];
     addonId?: string;
     extraId?: string;
+    name?: string;
 };
 
 export interface RadioState {
     hover?: boolean;
     addonId?: string;
     extraId?: string;
+    focusVisible?: boolean;
 }
 
 export { RadioChangeEvent };
@@ -116,7 +118,10 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
             },
             setExtraId: () => {
                 this.setState({ extraId: getUuidShort({ prefix: 'extra' }) });
-            }
+            },
+            setFocusVisible: (focusVisible: boolean): void => {
+                this.setState({ focusVisible });
+            },
         };
     }
 
@@ -152,6 +157,14 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
         this.foundation.setHover(false);
     };
 
+    handleFocusVisible = (event: React.FocusEvent) => {
+        this.foundation.handleFocusVisible(event);
+    }
+
+    handleBlur = (event: React.FocusEvent) => {
+        this.foundation.handleBlur();
+    }
+
     render() {
         const {
             addonClassName,
@@ -166,7 +179,8 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
             extra,
             mode,
             type,
-            value: propValue
+            value: propValue,
+            name
         } = this.props;
 
         let realChecked,
@@ -178,7 +192,7 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
             isButtonRadioComponent,
             buttonSize,
             realPrefixCls;
-        const { hover: isHover, addonId, extraId } = this.state;
+        const { hover: isHover, addonId, extraId, focusVisible } = this.state;
         let props = {};
 
         if (this.isInGroup()) {
@@ -202,6 +216,8 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
 
         const prefix = realPrefixCls || css.PREFIX;
 
+        const focusOuter = isCardRadioGroup || isPureCardRadioGroup || isButtonRadio;
+
         const wrapper = cls(prefix, {
             [`${prefix}-disabled`]: isDisabled,
             [`${prefix}-checked`]: realChecked,
@@ -215,9 +231,10 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
             [`${prefix}-cardRadioGroup_checked_disabled`]: isCardRadioGroup && realChecked && isDisabled,
             [`${prefix}-cardRadioGroup_hover`]: isCardRadioGroup && !realChecked && isHover && !isDisabled,
             [className]: Boolean(className),
+            [`${prefix}-focus`]: focusVisible && (isCardRadioGroup || isPureCardRadioGroup),
         });
 
-        const name = this.isInGroup() && this.context.radioGroup.name;
+        const groupName = this.isInGroup() && this.context.radioGroup.name;
         const addonCls = cls({
             [`${prefix}-addon`]: !isButtonRadio,
             [`${prefix}-addon-buttonRadio`]: isButtonRadio,
@@ -225,6 +242,7 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
             [`${prefix}-addon-buttonRadio-disabled`]: isButtonRadio && isDisabled,
             [`${prefix}-addon-buttonRadio-hover`]: isButtonRadio && !realChecked && !isDisabled && isHover,
             [`${prefix}-addon-buttonRadio-${buttonSize}`]: isButtonRadio && buttonSize,
+            [`${prefix}-focus`]: focusVisible && isButtonRadio,
         }, addonClassName);
         const renderContent = () => (
             <>
@@ -232,6 +250,7 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
                 {extra && !isButtonRadio ? <div className={`${prefix}-extra`} id={extraId}>{extra}</div> : null}
             </>
         );
+
         return (
             <label
                 style={style}
@@ -243,7 +262,7 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
                     {...this.props}
                     {...props}
                     mode={realMode}
-                    name={name}
+                    name={name ?? groupName}
                     isButtonRadio={isButtonRadio}
                     isPureCardRadioGroup={isPureCardRadioGroup}
                     onChange={this.onChange}
@@ -252,6 +271,9 @@ class Radio extends BaseComponent<RadioProps, RadioState> {
                     }}
                     addonId={children && addonId}
                     extraId={extra && extraId}
+                    focusInner={focusVisible && !focusOuter}
+                    onInputFocus={this.handleFocusVisible}
+                    onInputBlur={this.handleBlur}
                 />
                 {
                     isCardRadioGroup ?

+ 11 - 2
packages/semi-ui/radio/radioInner.tsx

@@ -23,6 +23,9 @@ export interface RadioInnerProps extends BaseProps {
     addonId?: string;
     extraId?: string;
     'aria-label'?: React.AriaAttributes['aria-label'];
+    focusInner?: boolean;
+    onInputFocus?: (e: any) => void;
+    onInputBlur?: (e: any) => void;
 }
 
 interface RadioInnerState {
@@ -39,6 +42,9 @@ class RadioInner extends BaseComponent<RadioInnerProps, RadioInnerState> {
         onChange: PropTypes.func,
         mode: PropTypes.oneOf(['advanced', '']),
         'aria-label': PropTypes.string,
+        focusInner: PropTypes.bool,
+        onInputFocus: PropTypes.func,
+        onInputBlur: PropTypes.func,
     };
 
     static defaultProps = {
@@ -97,7 +103,7 @@ class RadioInner extends BaseComponent<RadioInnerProps, RadioInnerState> {
     }
 
     render() {
-        const { disabled, mode, autoFocus, name, isButtonRadio, isPureCardRadioGroup, addonId, extraId, 'aria-label': ariaLabel } = this.props;
+        const { disabled, mode, autoFocus, name, isButtonRadio, isPureCardRadioGroup, addonId, extraId, 'aria-label': ariaLabel, focusInner, onInputFocus, onInputBlur } = this.props;
         const { checked } = this.state;
 
         const prefix = this.props.prefixCls || css.PREFIX;
@@ -110,6 +116,8 @@ class RadioInner extends BaseComponent<RadioInnerProps, RadioInnerState> {
         });
 
         const inner = classnames({
+            [`${prefix}-focus`]: focusInner,
+            [`${prefix}-focus-border`]:  focusInner && !checked,
             [`${prefix}-inner-display`]: !isButtonRadio,
         });
 
@@ -119,7 +127,6 @@ class RadioInner extends BaseComponent<RadioInnerProps, RadioInnerState> {
                     ref={ref => {
                         this.inputEntity = ref;
                     }}
-                    // eslint-disable-next-line jsx-a11y/no-autofocus
                     autoFocus={autoFocus}
                     type={mode === 'advanced' ? 'checkbox' : 'radio'}
                     checked={Boolean(checked)}
@@ -129,6 +136,8 @@ class RadioInner extends BaseComponent<RadioInnerProps, RadioInnerState> {
                     aria-label={ariaLabel}
                     aria-labelledby={addonId}
                     aria-describedby={extraId}
+                    onFocus={onInputFocus}
+                    onBlur={onInputBlur}
                 />
                 <span className={inner}>{checked ? <IconRadio /> : null}</span>
             </span>