Sfoglia il codice sorgente

feat(a11y): avatar add focus & keyboard event #205 (#926)

* feat(a11y): avatar add focus & keyboard event

* fix: [Avatar] optimize code
YyumeiZhang 3 anni fa
parent
commit
9a1b408e3a

+ 48 - 44
content/show/avatar/index-en-US.md

@@ -26,23 +26,23 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar size="extra-extra-small" style={{ margin: 4 }}>
+        <Avatar size="extra-extra-small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="extra-small" style={{ margin: 4 }}>
+        <Avatar size="extra-small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="small" style={{ margin: 4 }}>
+        <Avatar size="small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="default" style={{ margin: 4 }}>
+        <Avatar size="default" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar style={{ margin: 4 }}>U</Avatar>
-        <Avatar size="large" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt='User'>U</Avatar>
+        <Avatar size="large" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="extra-large" style={{ margin: 4 }}>
+        <Avatar size="extra-large" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
     </div>
@@ -59,15 +59,15 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar style={{ margin: 4 }}>AS</Avatar>
-        <Avatar color="red" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt='Alice Swift'>AS</Avatar>
+        <Avatar color="red" style={{ margin: 4 }} alt='Bob Matteo'>
             BM
         </Avatar>
-        <Avatar color="light-blue" style={{ margin: 4 }}>
+        <Avatar color="light-blue" style={{ margin: 4 }} alt='Taylor Joy'>
             TJ
         </Avatar>
-        <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf', margin: 4 }}>ZL</Avatar>
-        <Avatar style={{ backgroundColor: '#87d068', margin: 4 }}>YZ</Avatar>
+        <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf', margin: 4 }} alt='Zank Lance'>ZL</Avatar>
+        <Avatar style={{ backgroundColor: '#87d068', margin: 4 }} alt='Youself Zhang'>YZ</Avatar>
     </div>
 );
 ```
@@ -83,12 +83,12 @@ import { Avatar } from '@douyinfe/semi-ui';
 () => (
     <div>
         <Avatar
-            alt="a cat"
+            alt="beautiful cat"
             src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
             style={{ margin: 4 }}
         />
         <Avatar
-            alt="a cat"
+            alt="cute cat"
             size="small"
             src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
             style={{ margin: 4 }}
@@ -107,8 +107,8 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar style={{ margin: 4 }}>U</Avatar>
-        <Avatar shape="square" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt="User">U</Avatar>
+        <Avatar shape="square" style={{ margin: 4 }} alt="User">
             U
         </Avatar>
     </div>
@@ -141,7 +141,7 @@ import { IconCamera } from '@douyinfe/semi-icons';
     );
 
     return (
-        <Avatar hoverMask={hover} color="red">
+        <Avatar hoverMask={hover} color="red" alt='Bob Downton'>
             BD
         </Avatar>
     );
@@ -156,14 +156,17 @@ You can use `AvatarGroup` component to display avatars as a group.
 import React from 'react';
 import { Avatar, AvatarGroup } from '@douyinfe/semi-ui';
 
+import React from 'react';
+import { AvatarGroup, Avatar } from '@douyinfe/semi-ui';
+
 () => (
     <div>
         <AvatarGroup>
-            <Avatar color="red">LL</Avatar>
-            <Avatar>CX</Avatar>
-            <Avatar color="amber">RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     </div>
 );
@@ -177,11 +180,11 @@ import { Avatar, AvatarGroup } from '@douyinfe/semi-ui';
 () => (
     <div>
         <AvatarGroup maxCount={3}>
-            <Avatar color="red">LL</Avatar>
-            <Avatar>CX</Avatar>
-            <Avatar color="amber">RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     </div>
 );
@@ -215,11 +218,11 @@ function Demo() {
 
     return (
         <AvatarGroup maxCount={3} renderMore={renderMore}>
-            <Avatar color='red'>LL</Avatar>
-            <Avatar >CX</Avatar>
-            <Avatar color='amber'>RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }} >YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     );
 }
@@ -234,20 +237,20 @@ import { Avatar, AvatarGroup } from '@douyinfe/semi-ui';
     <div>
         <div>
             <AvatarGroup overlapFrom={'start'}>
-                <Avatar color="red">LL</Avatar>
-                <Avatar>CX</Avatar>
-                <Avatar color="amber">RM</Avatar>
-                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-                <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+                <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+                <Avatar alt='Caroline Xiao'>CX</Avatar>
+                <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+                <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
             </AvatarGroup>
         </div>
         <div>
             <AvatarGroup overlapFrom={'end'}>
-                <Avatar color="red">LL</Avatar>
-                <Avatar>CX</Avatar>
-                <Avatar color="amber">RM</Avatar>
-                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-                <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+                <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+                <Avatar alt='Caroline Xiao'>CX</Avatar>
+                <Avatar color="amber"  alt='Rafal Matin'>RM</Avatar>
+                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+                <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
             </AvatarGroup>
         </div>
     </div>
@@ -289,8 +292,9 @@ import { Avatar, AvatarGroup } from '@douyinfe/semi-ui';
 
 ## Accessibility
 
-- `alt`:When using a picture avatar, please use the `alt` attribute to explain the content of the picture
-
+- Avatars are generally not used for operations and do not need to be focused. But when the Avatar can be clicked (such as the avatar on the Semi official website), it needs to be focused and respond to the keyboard `Enter` event.
+- When Avatar is used in combination with other components, also check the accessibility guidelines for that component.
+- Avatar's `alt` attribute can be read by screen readers, when using the avatar component, please use the `alt` attribute to explain the content of the image.
 ```jsx
 import React from 'react';
 import { Avatar } from '@douyinfe/semi-ui';

+ 45 - 44
content/show/avatar/index.md

@@ -25,23 +25,23 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar size="extra-extra-small" style={{ margin: 4 }}>
+        <Avatar size="extra-extra-small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="extra-small" style={{ margin: 4 }}>
+        <Avatar size="extra-small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="small" style={{ margin: 4 }}>
+        <Avatar size="small" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="default" style={{ margin: 4 }}>
+        <Avatar size="default" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar style={{ margin: 4 }}>U</Avatar>
-        <Avatar size="large" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt='User'>U</Avatar>
+        <Avatar size="large" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
-        <Avatar size="extra-large" style={{ margin: 4 }}>
+        <Avatar size="extra-large" style={{ margin: 4 }} alt='User'>
             U
         </Avatar>
     </div>
@@ -58,15 +58,15 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar style={{ margin: 4 }}>AS</Avatar>
-        <Avatar color="red" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt='Alice Swift'>AS</Avatar>
+        <Avatar color="red" style={{ margin: 4 }} alt='Bob Matteo'>
             BM
         </Avatar>
-        <Avatar color="light-blue" style={{ margin: 4 }}>
+        <Avatar color="light-blue" style={{ margin: 4 }} alt='Taylor Joy'>
             TJ
         </Avatar>
-        <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf', margin: 4 }}>ZL</Avatar>
-        <Avatar style={{ backgroundColor: '#87d068', margin: 4 }}>YZ</Avatar>
+        <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf', margin: 4 }} alt='Zank Lance'>ZL</Avatar>
+        <Avatar style={{ backgroundColor: '#87d068', margin: 4 }} alt='Youself Zhang'>YZ</Avatar>
     </div>
 );
 ```
@@ -82,12 +82,12 @@ import { Avatar } from '@douyinfe/semi-ui';
 () => (
     <div>
         <Avatar
-            alt="a cat"
+            alt="beautiful cat"
             src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
             style={{ margin: 4 }}
         />
         <Avatar
-            alt="a cat"
+            alt="cute cat"
             size="small"
             src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
             style={{ margin: 4 }}
@@ -106,8 +106,8 @@ import { Avatar } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Avatar style={{ margin: 4 }}>U</Avatar>
-        <Avatar shape="square" style={{ margin: 4 }}>
+        <Avatar style={{ margin: 4 }} alt="User">U</Avatar>
+        <Avatar shape="square" style={{ margin: 4 }} alt="User">
             U
         </Avatar>
     </div>
@@ -139,7 +139,7 @@ import { IconCamera } from '@douyinfe/semi-icons';
     );
 
     return (
-        <Avatar hoverMask={hover} color="red">
+        <Avatar hoverMask={hover} color="red" alt='Bob Downton'>
             BD
         </Avatar>
     );
@@ -157,11 +157,11 @@ import { AvatarGroup, Avatar } from '@douyinfe/semi-ui';
 () => (
     <div>
         <AvatarGroup>
-            <Avatar color="red">LL</Avatar>
-            <Avatar>CX</Avatar>
-            <Avatar color="amber">RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     </div>
 );
@@ -176,11 +176,11 @@ import { AvatarGroup, Avatar } from '@douyinfe/semi-ui';
 () => (
     <div>
         <AvatarGroup maxCount={3}>
-            <Avatar color="red">LL</Avatar>
-            <Avatar>CX</Avatar>
-            <Avatar color="amber">RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     </div>
 );
@@ -216,11 +216,11 @@ function Demo() {
 
     return (
         <AvatarGroup maxCount={3} renderMore={renderMore}>
-            <Avatar color="red">LL</Avatar>
-            <Avatar>CX</Avatar>
-            <Avatar color="amber">RM</Avatar>
-            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-            <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+            <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+            <Avatar alt='Caroline Xiao'>CX</Avatar>
+            <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+            <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+            <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
         </AvatarGroup>
     );
 }
@@ -236,20 +236,20 @@ import { AvatarGroup, Avatar } from '@douyinfe/semi-ui';
     <div>
         <div>
             <AvatarGroup overlapFrom={'start'}>
-                <Avatar color="red">LL</Avatar>
-                <Avatar>CX</Avatar>
-                <Avatar color="amber">RM</Avatar>
-                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-                <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+                <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+                <Avatar alt='Caroline Xiao'>CX</Avatar>
+                <Avatar color="amber" alt='Rafal Matin'>RM</Avatar>
+                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+                <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
             </AvatarGroup>
         </div>
         <div>
             <AvatarGroup overlapFrom={'end'}>
-                <Avatar color="red">LL</Avatar>
-                <Avatar>CX</Avatar>
-                <Avatar color="amber">RM</Avatar>
-                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }}>ZL</Avatar>
-                <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
+                <Avatar color="red" alt='Lisa LeBlanc'>LL</Avatar>
+                <Avatar alt='Caroline Xiao'>CX</Avatar>
+                <Avatar color="amber"  alt='Rafal Matin'>RM</Avatar>
+                <Avatar style={{ color: '#f56a00', backgroundColor: '#fde3cf' }} alt='Zank Lance'>ZL</Avatar>
+                <Avatar style={{ backgroundColor: '#87d068' }} alt='Youself Zhang'>YZ</Avatar>
             </AvatarGroup>
         </div>
     </div>
@@ -291,8 +291,9 @@ import { AvatarGroup, Avatar } from '@douyinfe/semi-ui';
 
 ## Accessibility
 
-- `alt`:使用图片头像时,请使用 `alt` 属性解释图片的内容
-
+- Avatar 一般不用于操作,不需要被获取焦点。但当 Avatar 可以被点击操作时(如:Semi 官网上方的头像)需要被聚焦,并响应键盘 `Enter` 事件。
+- 当 Avatar 与其他组件结合使用时,需要同时检查该组件的可访问性指南。
+- Avatar的`alt`属性可以被屏幕阅读器读取,使用头像组件时,请使用`alt` 属性解释头像的内容。
 ```jsx
 import React from 'react';
 import { Avatar } from '@douyinfe/semi-ui';

+ 33 - 0
cypress/integration/avatar.spec.js

@@ -0,0 +1,33 @@
+describe('avatar', () => {
+    it('keyboard test', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=avatar--focus-test&args=&viewMode=story', {
+            onBeforeLoad(win) {
+                cy.stub(win.console, 'log').as('consoleLog'); // 测试时用到控制台的前置步骤
+            },
+        });
+
+        // focus + esc + enter
+        cy.get('#initial_focus_point').click();
+        cy.wait(100);
+        cy.get('#initial_focus_point').tab(); // 按下tab键
+        cy.wait(100);
+        cy.get('.semi-avatar').eq(0).should('have.class', 'semi-avatar-focus'); // 第一个avatar应该有focus样式
+        cy.get('.semi-avatar').eq(0).type('{enter}'); // 在第一个tag上按enter
+        cy.wait(100);
+        cy.get('@consoleLog').should('be.calledWith', 'click avatar 1'); // 控制台应该打印“click avatar 1”
+        cy.get('.semi-avatar').eq(0).type('{esc}'); // 在第一个tag上按ESC
+        cy.wait(100);
+        cy.get('.semi-avatar').eq(0).should('not.have.class', 'semi-avatar-focus'); // 第一个avatar应该无focus样式
+        cy.get('.semi-avatar').eq(0).type('{backspace}'); // 模拟聚焦状态按其他按键的兜底
+    });
+
+    it('src Change', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=avatar--src-change&args=&viewMode=story');
+        cy.get('.semi-radio').eq(1).click(); // 点击第二个按钮,切换src为successSrc2
+        cy.wait(500);
+        cy.get('.semi-avatar').eq(0).should('have.class', 'semi-avatar-img'); // 图片成功加载,avatar应该具备class: semi-avatar-img
+        cy.get('.semi-radio').eq(2).click(); // 点击第三个按钮,切换src为errorSrc
+        cy.wait(1000);
+        cy.get('.semi-avatar').eq(0).should('have.class', 'semi-avatar-grey'); // 图片加载失败,avatar应该具备class: semi-avatar-grey
+    });
+});

+ 14 - 0
packages/semi-foundation/avatar/avatar.scss

@@ -16,6 +16,20 @@ $colors: "amber", "blue", "cyan", "green", "grey", "indigo", "light-blue", "ligh
     text-align: center;
     vertical-align: middle;
 
+    &:focus-visible {
+        outline: $width-avatar-outline solid $color-avatar-outline-focus;
+    }
+
+    &-focus {
+        outline: $width-avatar-outline solid $color-avatar-outline-focus;
+    }
+
+    &-no-focus-visible {
+        &:focus-visible {
+            outline: none;
+        }
+    }
+
     .#{$module}-label {
         display: flex;
         align-items: center;

+ 17 - 0
packages/semi-foundation/avatar/foundation.ts

@@ -1,9 +1,11 @@
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
+import warning from '../utils/warning';
 
 export interface AvatarAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     notifyImgState(isImgExist: boolean): void;
     notifyLeave(event: any): void;
     notifyEnter(event: any): void;
+    setFocusVisible: (focusVisible: boolean) => void;
 }
 
 export default class AvatarFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<AvatarAdapter<P, S>, P, S> {
@@ -32,4 +34,19 @@ export default class AvatarFoundation<P = Record<string, any>, S = Record<string
         this._adapter.notifyLeave(e);
     }
 
+    handleFocusVisible = (event: any) => {
+        const { target } = event;
+        try {
+            if (target.matches(':focus-visible')) {
+                this._adapter.setFocusVisible(true);
+            }
+        } catch (error){
+            warning(true, 'Warning: [Semi Avatar] The current browser does not support the focus-visible');
+        }
+    }
+
+    handleBlur = () => {
+        this._adapter.setFocusVisible(false);
+    }
+
 }

+ 3 - 0
packages/semi-foundation/avatar/variables.scss

@@ -2,6 +2,7 @@ $z-avatar-default: 100; // 头像 z-index
 // color
 $color-avatar_default-border-default: var(--semi-color-bg-1); // 头像描边颜色
 $color-avatar_more_default-bg-default: rgba(var(--semi-grey-5), 1); // 「更多」描边颜色
+$color-avatar-outline-focus: var(--semi-color-primary-light-active); // 头像聚焦轮廓颜色
 
 
 $width-avatar_extra_extra_small: 20px; // 头像尺寸 - 极小
@@ -43,3 +44,5 @@ $spacing-avatar_extra_small-marginLeft: -10px; // 头像左侧外边距 - 超小
 
 $width-avatar_extra_extra_small-border: 1px; // 头像描边尺寸 - 极小
 $spacing-avatar_extra_extra_small-marginLeft: -4px; // 头像左侧外边距 - 极小
+
+$width-avatar-outline: 2px //头像聚焦轮廓宽度

+ 2 - 1
packages/semi-foundation/checkbox/checkboxFoundation.ts

@@ -1,5 +1,6 @@
 import BaseFoundation, { DefaultAdapter, noopFunction } from '../base/foundation';
 import isEnterPress from '../utils/isEnterPress';
+import warning from '../utils/warning';
 
 export interface BasicTargetObject {
     [x: string]: any;
@@ -139,7 +140,7 @@ class CheckboxFoundation<P = Record<string, any>, S = Record<string, any>> exten
                 this._adapter.setFocusVisible(true);
             }
         } catch (error){
-            console.warn('The current browser does not support the focus-visible');
+            warning(true, 'Warning: [Semi Checkbox] The current browser does not support the focus-visible');
         }
     }
 

+ 2 - 1
packages/semi-foundation/radio/radioFoundation.ts

@@ -1,4 +1,5 @@
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
+import warning from '../utils/warning';
 
 export interface RadioAdapter extends DefaultAdapter {
     setHover: (hover: boolean) => void;
@@ -27,7 +28,7 @@ export default class RadioFoundation extends BaseFoundation<RadioAdapter> {
                 this._adapter.setFocusVisible(true);
             }
         } catch (error){
-            console.warn('The current browser does not support the focus-visible');
+            warning(true, 'Warning: [Semi Radio] The current browser does not support the focus-visible');
         }
     }
 

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

@@ -1,4 +1,5 @@
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
+import warning from '../utils/warning';
 
 export interface SwitchAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     setNativeControlChecked: (nativeControlChecked: boolean | undefined) => void;
@@ -45,7 +46,7 @@ export default class SwitchFoundation<P = Record<string, any>, S = Record<string
                 this._adapter.setFocusVisible(true);
             }
         } catch (error){
-            console.warn('The current browser does not support the focus-visible');
+            warning(true, 'Warning: [Semi Switch] The current browser does not support the focus-visible');
         }
     }
 

+ 62 - 5
packages/semi-ui/avatar/_story/avatar.stories.js

@@ -1,7 +1,5 @@
-import React from 'react';
-import Avatar from '../index';
-import Popover from '../../popover/index';
-import AvatarGroup from '../avatarGroup';
+import React, {useState} from 'react';
+import { Avatar,Popover, AvatarGroup, RadioGroup, Radio } from '../../index';
 
 export default {
   title: 'Avatar',
@@ -188,4 +186,63 @@ export const ExtraExtraSmallOverlap = () => (
       <Avatar style={{ backgroundColor: '#87d068' }}>YZ</Avatar>
     </AvatarGroup>
   </div>
-);
+);
+
+export const focusTest = () => {
+  return (
+    <>
+      <div id='initial_focus_point' tabindex={0} style={{width: 10, height: 10}}></div>
+      <Avatar
+        alt="a cat 1"
+        src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
+        style={{ margin: 4 }}
+        onClick={()=>{
+            console.log('click avatar 1');
+        }}
+      />
+      <Avatar
+        alt="a cat 2"
+        size="small"
+        src="https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg"
+        style={{ margin: 4 }}
+        onClick={()=>{
+            console.log('click avatar 2');
+        }}
+      />
+    </>
+  );
+}
+
+const srcGroup = {
+  'successSrc1': 'https://sf6-cdn-tos.douyinstatic.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/avatarDemo.jpeg',
+  'successSrc2': 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
+  'errorSrc': 'https://xxx.png',
+}
+
+export const srcChange = () => {
+  const [src, setSrc] = useState('successSrc1');
+
+  const onChange = (e) => {
+    setSrc(e.target.value);
+  } 
+
+  return (
+    <>
+      <div>点击选择src类型</div>
+      <RadioGroup onChange={onChange} value={src} aria-label="单选组合示例">
+        <Radio value={'successSrc1'}>成功的src</Radio>
+        <Radio value={'successSrc2'}>成功的src</Radio>
+        <Radio value={'errorSrc'}>失败的src</Radio>
+      </RadioGroup>
+      <br />
+      <Avatar
+        alt="test image"
+        src={srcGroup[src]}
+        style={{ margin: 4 }}
+        onError={() => {
+          console.log('loading error');
+        }}
+      />
+    </>
+  )
+}

+ 16 - 4
packages/semi-ui/avatar/avatarGroup.tsx

@@ -1,4 +1,4 @@
-import React, { PureComponent, Fragment } from 'react';
+import React, { PureComponent, Fragment, ReactElement } from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
 import { get as lodashGet, isFunction, isNumber } from 'lodash';
@@ -28,7 +28,10 @@ export default class AvatarGroup extends PureComponent<AvatarGroupProps> {
 
     getAllAvatars() {
         const { children } = this.props;
-        return Array.isArray(children) ? children : [children];
+        if (children) {
+            return Array.isArray(children) ? React.Children.toArray(children) : [children];
+        }
+        return [];
     }
 
     getMergeAvatars(avatars: React.ReactNode[]) {
@@ -50,7 +53,16 @@ export default class AvatarGroup extends PureComponent<AvatarGroupProps> {
     renderMoreAvatar(restNumber: number, restAvatars: React.ReactNode[]) {
         const { renderMore } = this.props;
         const moreCls = cls(`${prefixCls}-item-more`);
-        let moreAvatar = <Avatar className={moreCls} key="_+n">{`+${restNumber}`}</Avatar>;
+        const restAvatarAlt = restAvatars?.reduce((pre, cur) => {
+            const { children, alt } = (cur as ReactElement).props;
+            const avatarInfo = alt ?? ((typeof children === 'string') ? children : '');
+            if (avatarInfo.length === 0) {
+                return pre;
+            }
+            return (pre as string).length > 0 ? `${pre},${avatarInfo}` : avatarInfo;
+        }, '');
+        const finalAlt = ` Number of remaining Avatars:${restNumber},${restAvatarAlt}`;
+        let moreAvatar = <Avatar className={moreCls} key="_+n" alt={finalAlt}>{`+${restNumber}`}</Avatar>;
         if (isFunction(renderMore)) {
             moreAvatar = <Fragment key="_+n">{renderMore(restNumber, restAvatars)}</Fragment>;
         }
@@ -76,6 +88,6 @@ export default class AvatarGroup extends PureComponent<AvatarGroupProps> {
 
         }
 
-        return <div className={groupCls}>{inner}</div>;
+        return <div className={groupCls} role='list'>{inner}</div>;
     }
 }

+ 87 - 16
packages/semi-ui/avatar/index.tsx

@@ -7,6 +7,7 @@ import '@douyinfe/semi-foundation/avatar/avatar.scss';
 import { noop } from '@douyinfe/semi-foundation/utils/function';
 import BaseComponent from '../_base/baseComponent';
 import { AvatarProps } from './interface';
+import { handlePrevent } from '@douyinfe/semi-foundation/utils/a11y';
 
 const sizeSet = strings.SIZE;
 const shapeSet = strings.SHAPE;
@@ -17,6 +18,7 @@ export * from './interface';
 export interface AvatarState {
     isImgExist: boolean;
     hoverContent: React.ReactNode;
+    focusVisible: boolean;
 }
 
 export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
@@ -53,10 +55,13 @@ export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
         this.state = {
             isImgExist: true,
             hoverContent: '',
+            focusVisible: false,
         };
         this.onEnter = this.onEnter.bind(this);
         this.onLeave = this.onLeave.bind(this);
         this.handleError = this.handleError.bind(this);
+        this.handleKeyDown = this.handleKeyDown.bind(this);
+        this.getContent = this.getContent.bind(this);
     }
 
     get adapter(): AvatarAdapter<AvatarProps, AvatarState> {
@@ -78,7 +83,10 @@ export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
                     const { onMouseLeave } = this.props;
                     onMouseLeave && onMouseLeave(e);
                 });
-            }
+            },
+            setFocusVisible: (focusVisible: boolean): void => {
+                this.setState({ focusVisible });
+            },
         };
     }
 
@@ -119,10 +127,82 @@ export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
         this.foundation.handleImgLoadError();
     }
 
+    handleKeyDown(event: any) {
+        const { onClick } = this.props;
+        switch (event.key) {
+            case "Enter":
+                onClick(event);
+                handlePrevent(event);
+                break;
+            case 'Escape':
+                event.target.blur();
+                break;
+            default:
+                break;
+        }
+    }
+    
+    handleFocusVisible = (event: React.FocusEvent) => {
+        this.foundation.handleFocusVisible(event);
+    }
+
+    handleBlur = (event: React.FocusEvent) => {
+        this.foundation.handleBlur();
+    }
+
+    getContent = () => {
+        const { children, onClick, imgAttr, src, srcSet, alt } = this.props;
+        const { isImgExist } = this.state;
+        let content = children;
+        const clickable = onClick !== noop;
+        const isImg = src && isImgExist;
+        const a11yFocusProps = { 
+            tabIndex: 0, 
+            onKeyDown: this.handleKeyDown,
+            onFocus: this.handleFocusVisible,
+            onBlur: this.handleBlur,
+        };
+        if (isImg) {
+            const finalAlt = clickable ? `clickable Avatar: ${alt}` : alt; 
+            const imgBasicProps = {
+                src,
+                srcSet,
+                onError: this.handleError,
+                ...imgAttr,
+                className: cls({
+                    [`${prefixCls}-no-focus-visible`]: clickable,
+                }),
+            };
+            const imgProps = clickable ? { ...imgBasicProps, ...a11yFocusProps } : imgBasicProps;
+            content = (
+                <img alt={finalAlt} {...imgProps}/>
+            );
+        } else if (typeof children === 'string') {
+            const tempAlt = alt ?? children;
+            const finalAlt = clickable ? `clickable Avatar: ${tempAlt}` : tempAlt; 
+            const props = {
+                role: 'img',
+                'aria-label': finalAlt,
+                className:  cls(`${prefixCls}-label`,
+                    {
+                        [`${prefixCls}-no-focus-visible`]: clickable,
+                    }
+                ),
+            };
+            const finalProps = clickable ? { ...props, ...a11yFocusProps } : props;
+            content = (
+                <span className={`${prefixCls}-content`}>
+                    <span {...finalProps}  x-semi-prop="children">{children}</span>
+                </span>
+            );
+        }
+        return content;
+    }
+
     render() {
         // eslint-disable-next-line max-len, no-unused-vars
         const { shape, children, size, color, className, hoverMask, onClick, imgAttr, src, srcSet, style, alt, ...others } = this.props;
-        const { isImgExist, hoverContent } = this.state;
+        const { isImgExist, hoverContent, focusVisible } = this.state;
         const isImg = src && isImgExist;
         const avatarCls = cls(
             prefixCls,
@@ -131,24 +211,14 @@ export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
                 [`${prefixCls}-${size}`]: size,
                 [`${prefixCls}-${color}`]: color && !isImg,
                 [`${prefixCls}-img`]: isImg,
+                [`${prefixCls}-focus`]: focusVisible,
             },
             className
         );
-        let content = children;
+
         const hoverRender = hoverContent ? (<div className={`${prefixCls}-hover`} x-semi-prop="hoverContent">{hoverContent}</div>) : null;
-        if (isImg) {
-            content = (
-                <img src={src} srcSet={srcSet} onError={this.handleError} alt={alt} {...imgAttr} />
-            );
-        } else if (typeof children === 'string') {
-            content = (
-                <span className={`${prefixCls}-content`}>
-                    <span className={`${prefixCls}-label`} x-semi-prop="children">{children}</span>
-                </span>
-            );
-        }
+        
         return (
-            // eslint-disable-next-line jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events
             <span
                 {...(others as any)}
                 style={style}
@@ -156,8 +226,9 @@ export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
                 onClick={onClick as any}
                 onMouseEnter={this.onEnter as any}
                 onMouseLeave={this.onLeave as any}
+                role='listitem'
             >
-                {content}
+                {this.getContent()}
                 {hoverRender}
             </span>
         );