Преглед изворни кода

feat(a11y): rating add focus & keyboard event #205 (#946)

* feat(a11y): rating add focus & keyboard event #205

* fix: [Rating] optimize code

* fix: [rating] fix spelling mistakes

* fix: [rating] code optimize

* fix: accessibility description optimize
YyumeiZhang пре 3 година
родитељ
комит
7bd46abcda

+ 15 - 4
content/input/rating/index-en-US.md

@@ -47,7 +47,7 @@ import { Rating } from '@douyinfe/semi-ui';
     <div>
         <Rating allowHalf defaultValue={3.5} />
         <br />
-        <Rating allowHalf defaultValue={3.65} />
+        <Rating allowHalf defaultValue={3.65} disabled/>
     </div>
 );
 ```
@@ -178,11 +178,22 @@ import { IconLikeHeart } from '@douyinfe/semi-icons';
 | tooltips      | Customize prompted information for each item                                          | String[]                | -                                        |
 | value         | Controlled value                                                                      | number                  | -                                        |
 
-##Accessibility
+## Accessibility
 
 ### ARIA
-
-- Rating has aria-checked to indicate whether it is currently selected, aria-posinset to indicate the position in the list, and aria-setsize to indicate the length of the list
+- Rating has `aria-checked` to indicate whether it is currently selected, `aria-posinset` to indicate the position in the list, and `aria-setsize` to indicate the length of the list.
+- Semi supports custom Rating semantics
+  - Users can use `aria-label` to customize the semantics of Rating;
+  - If the type of `character` passed in by the user is string, this string will be used for the semantics of Rating;
+  - `aria-label` has higher priority than string type `character`.
+
+### Keyboard and Focus
+- Initial focus settings for Rating:
+  - If there is a selection item in Rating, the initial focus should be set to the last selection item (for example: if 3 🌟 are lit, the initial focus is set on the third lit 🌟);
+  - If there is no option for Rating, the initial focus should be on the entire Rating.
+- On a Rating group, you can use the `right arrow` or `up arrow` to select the next focus item of the current focus, and the `left arrow` or `down arrow` to select the previous focus item of the current focus;
+    - The user sets the `allowHalf` property, and presses the arrow keys to select or deselect only half a star;
+- A disabled Rating cannot get the focus.
 
 ## Design Tokens
 <DesignToken/>

+ 14 - 3
content/input/rating/index.md

@@ -47,7 +47,7 @@ import { Rating } from '@douyinfe/semi-ui';
     <div>
         <Rating allowHalf defaultValue={3.5}/>
         <br/>
-        <Rating allowHalf defaultValue={3.65}/>
+        <Rating allowHalf defaultValue={3.65} disabled/>
     </div>
 );
 ```
@@ -158,8 +158,19 @@ import { IconLikeHeart } from '@douyinfe/semi-icons';
 ## Accessibility
 
 ### ARIA
-
-- Rating 具有 aria-checked 表示当前是否选中,aria-posinset 表示在列表的位置,aria-setsize 表示列表的长度
+- Rating 具有 `aria-checked` 表示当前是否选中,`aria-posinset` 表示在列表的位置,`aria-setsize` 表示列表的长度。
+- Semi 支持自定义 Rating 的语义:
+  - 可以使用 `aria-label` 来定制 Rating 的语义化;
+  - 若用户传入的 `character` 类型为 string,将使用这个 string 来做 Rating 的语义化;
+  - `aria-label`的优先级高于string的`character`。
+
+### 键盘和焦点
+- Rating 的初始焦点设置:
+  - 若 Rating 有选择项时,初始焦点应当设置为最后一个选择项时(如:有 3颗🌟被点亮,则初始焦点设置在第三颗被点亮的🌟上);
+  - 若 Rating 没有选择项时,初始焦点应当为整个 Rating。
+- 一个 Rating 组上,可以通过 `右箭头` 或 `上箭头` 选中当前焦点的下一个焦点项,`左箭头` 或 `下箭头` 选中当前焦点的上一个焦点项;
+  - 用户设置了 `allowHalf` 属性,按方向键只选中或取消选中半颗星;
+- `disabled`的 Rating 无法被获取到焦点。
 
 ## 设计变量
 <DesignToken/>

+ 31 - 0
cypress/integration/rating.spec.js

@@ -0,0 +1,31 @@
+describe('rating', () => {
+    it('radio with extra', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=rating--tooltip-rating&args=&viewMode=story');
+        // test down & right arrow
+        cy.get('.semi-rating-star-second').eq(3).click();
+        cy.get('#rating-result').contains('good');
+        cy.get('.semi-rating-star-second').eq(3).type('{upArrow}');
+        cy.get('#rating-result').contains('wonderful');
+        cy.get('.semi-rating-star-second').eq(4).type('{upArrow}');
+        cy.get('#rating-result').should('not.exist');
+        cy.get('.semi-rating-star-second').eq(5).type('{rightArrow}', { force: true });
+        cy.get('#rating-result').contains('terrible');
+        // test left & up Arrow
+        cy.get('.semi-rating-star-second').eq(1).click();
+        cy.get('#rating-result').contains('bad');
+        cy.get('.semi-rating-star-second').eq(1).type('{leftArrow}');
+        cy.get('#rating-result').contains('terrible');
+        cy.get('.semi-rating-star-second').eq(0).type('{downArrow}');
+        cy.get('#rating-result').should('not.exist');
+
+    });
+
+    it('autoFocus',  () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=rating--auto-focus&args=&viewMode=story');
+        cy.get('.semi-rating-star-second').eq(1).should('be.focused');
+        cy.get('.semi-rating-star-second').eq(1).type('{upArrow}');
+        cy.get('.semi-rating-star-second').eq(2).should('be.focused');
+        cy.get('.semi-rating-star-second').eq(2).type('{downArrow}');
+        cy.get('.semi-rating-star-second').eq(1).should('be.focused');
+    });
+});

+ 90 - 31
packages/semi-foundation/rating/foundation.ts

@@ -1,10 +1,6 @@
 /* eslint-disable no-param-reassign */
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
-
-const KeyCode = {
-    LEFT: 37,
-    RIGHT: 39
-};
+import warning from '../utils/warning';
 
 export interface RatingAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
     focus: () => void;
@@ -15,6 +11,7 @@ export interface RatingAdapter<P = Record<string, any>, S = Record<string, any>>
     notifyFocus: (e: any) => void;
     notifyBlur: (e: any) => void;
     notifyKeyDown: (e: any) => void;
+    setEmptyStarFocusVisible: (focusVisible: boolean) => void;
 }
 
 export default class RatingFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<RatingAdapter<P, S>, P, S> {
@@ -121,37 +118,99 @@ export default class RatingFoundation<P = Record<string, any>, S = Record<string
     }
 
     handleKeyDown(event: any, value: number) {
-        const { keyCode } = event;
+        const { key } = event;
         const { count, allowHalf } = this.getProps();
         const direction = this._adapter.getContext('direction');
         const reverse = direction === 'rtl';
-        if (keyCode === KeyCode.RIGHT && value < count && !reverse) {
-            if (allowHalf) {
-                value += 0.5;
-            } else {
-                value += 1;
-            }
-        } else if (keyCode === KeyCode.LEFT && value > 0 && !reverse) {
-            if (allowHalf) {
-                value -= 0.5;
-            } else {
-                value -= 1;
-            }
-        } else if (keyCode === KeyCode.RIGHT && value > 0 && reverse) {
-            if (allowHalf) {
-                value -= 0.5;
-            } else {
-                value -= 1;
+        const step = allowHalf ? 0.5 : 1;
+        let tempValue: number;
+        let newValue: number;
+        if (key === 'ArrowRight' || key === 'ArrowUp') {
+            tempValue = value + (reverse ?  - step : step);
+        } else if (key === 'ArrowLeft' || key === 'ArrowDown') {
+            tempValue = value + (reverse ? step : - step);
+        }
+        if (tempValue > count) {
+            newValue = 0;
+        } else if (tempValue < 0) {
+            newValue = count;
+        } else {
+            newValue = tempValue;
+        }
+        if (['ArrowRight', 'ArrowUp', 'ArrowLeft', 'ArrowDown'].includes(key)) {
+            this._adapter.notifyKeyDown(event);
+            this._adapter.updateValue(newValue);
+            this.changeFocusStar(newValue, event);
+            event.preventDefault();
+            this._adapter.notifyHoverChange(undefined, null);
+        }
+    }
+
+    changeFocusStar(value: number, event: any) {
+        const { count, allowHalf } = this.getProps();
+        const index = Math.ceil(value) - 1;
+        const starElement = [...event.currentTarget.childNodes].map(item => item.childNodes[0].childNodes);
+        if (index < 0) {
+            starElement[count][0].focus();
+        } else {
+            starElement[index][allowHalf ? (value * 10 % 10 === 5 ? 0 : 1) : 0].focus();
+        }
+    }
+
+    handleStarFocusVisible = (event: any) => {
+        const { target } = event;
+        const { count } = this.getProps();
+        // when rating 0 is focus visible
+        try {
+            if (target.matches(':focus-visible')) {
+                this._adapter.setEmptyStarFocusVisible(true);
             }
-        } else if (keyCode === KeyCode.LEFT && value < count && reverse) {
-            if (allowHalf) {
-                value += 0.5;
-            } else {
-                value += 1;
+        } catch (error) {
+            warning(true, 'Warning: [Semi Rating] The current browser does not support the focus-visible'); 
+        }
+    }
+
+    handleStarBlur = (event: React.FocusEvent) => {
+        const { emptyStarFocusVisible } = this.getStates();
+        if (emptyStarFocusVisible) {
+            this._adapter.setEmptyStarFocusVisible(false);
+        } 
+    }
+}
+
+export interface RatingItemAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    setFirstStarFocus: (value: boolean) => void;
+    setSecondStarFocus: (value: boolean) => void;
+}
+
+export class RatingItemFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<RatingItemAdapter<P, S>, P, S> {
+
+    constructor(adapter: RatingItemAdapter<P, S>) {
+        super({ ...RatingItemFoundation.defaultAdapter, ...adapter });
+    }
+
+    handleFocusVisible = (event: any, star: string) => {
+        const { target } = event;
+        // when rating 0 is focus visible
+        try {
+            if (target.matches(':focus-visible')) {
+                if (star === 'first') {
+                    this._adapter.setFirstStarFocus(true);
+                } else {
+                    this._adapter.setSecondStarFocus(true);
+                }
             }
+        } catch (error) {
+            warning(true, 'Warning: [Semi Rating] The current browser does not support the focus-visible'); 
+        }
+    }
+
+    handleBlur = (event: React.FocusEvent, star: string) => {
+        const { firstStarFocus, secondStarFocus } = this.getStates();
+        if (star === 'first') {
+            firstStarFocus && this._adapter.setFirstStarFocus(false);
+        } else {
+            secondStarFocus && this._adapter.setSecondStarFocus(false);
         }
-        this._adapter.updateValue(value);
-        event.preventDefault();
-        this._adapter.notifyKeyDown(event);
     }
 }

+ 21 - 8
packages/semi-foundation/rating/rating.scss

@@ -1,4 +1,4 @@
-@import "./variables.scss";
+@import './variables.scss';
 
 $module: #{$prefix}-rating;
 
@@ -7,10 +7,19 @@ $module: #{$prefix}-rating;
     margin: $spacing-rating-margin;
     padding: $spacing-rating-padding;
     color: $color-rating-icon-default;
-    font-size: $font-rating-fontSize;
-    line-height: unset;
+    // font-size: $font-rating-fontSize;
+    // line-height: unset;
     list-style: none;
     outline: none;
+    border-radius: 3px;
+
+    &-focus {
+        outline: $width-rating-outline-focus solid $color-rating-outline-focus;
+    }
+
+    &-no-focus {
+        outline: none;
+    }
 
     &-disabled &-star {
         cursor: default;
@@ -34,14 +43,14 @@ $module: #{$prefix}-rating;
         }
 
         & > div {
-            &:focus {
-                outline: 0;
-            }
-
             &:hover,
             &:focus {
                 transform: scale(1.1);
             }
+
+            &.#{$module}-star-disabled {
+                transform: none;
+            }
         }
 
         &-small {
@@ -58,6 +67,10 @@ $module: #{$prefix}-rating;
 
         &-wrapper {
             position: relative;
+            overflow: hidden;
+            border-radius: 3px;
+            width: 100%;
+            height: 100%;
         }
 
         &-first,
@@ -88,4 +101,4 @@ $module: #{$prefix}-rating;
     }
 }
 
-@import "./rtl.scss";
+@import './rtl.scss';

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

@@ -1,6 +1,8 @@
 $color-rating-icon-default: rgba(var(--semi-yellow-5), 1); // 评分图标按钮颜色 - 已填
 $color-rating-bg-default: var(--semi-color-fill-0); // 评分图标按钮颜色 - 未填
 
+$color-rating-outline-focus: var(--semi-color-primary-light-active); // 聚焦轮廓颜色
+
 $font-rating-fontSize: 20px; // 评分文本字体大小
 
 $spacing-rating-margin: 0px; // 整体外边距
@@ -12,3 +14,5 @@ $font-rating_item_small-fontSize: $width-rating_item_small; // 小尺寸评分
 
 $width-rating_item_default: 24px; // 评分项宽度
 $font-rating_item_default-fontSize: $width-rating_item_default; // 评分项文本字体大小
+
+$width-rating-outline-focus: 2px; // 聚焦轮廓宽度

+ 13 - 31
packages/semi-ui/rating/__test__/rating.test.js

@@ -23,7 +23,7 @@ describe('Rating', () => {
 
     it('custom count', () => {
         const R1 = mount(<Rating count={10} />);
-        expect(R1.find(`.${BASE_CLASS_PREFIX}-rating`).children().length).toEqual(10);
+        expect(R1.find(`.${BASE_CLASS_PREFIX}-rating`).children().length).toEqual(11);
     });
 
     it('different sizes', () => {
@@ -61,7 +61,7 @@ describe('Rating', () => {
         };
         const R = getRating(props);
         expect(
-            R.find(`.${BASE_CLASS_PREFIX}-rating-star-first`)
+            R.find(`.${BASE_CLASS_PREFIX}-rating-star-second`)
                 .at(0)
                 .getDOMNode().textContent
         ).toEqual('赞');
@@ -119,7 +119,7 @@ describe('Rating', () => {
             allowHalf: true
         };
         const R = getRating(props);
-        let stars = R.find('div[role="radio"]');
+        let stars = R.find(`.${BASE_CLASS_PREFIX}-rating-star-wrapper`);
         const event = {};
         stars.at(1).simulate('mouseMove', event);
         expect(spyHoverChange.calledWithMatch(2)).toBe(true);
@@ -173,20 +173,6 @@ describe('Rating', () => {
         expect(spyOnBlur.calledOnce).toBe(true);
     });
 
-    it('autoFocus &  ref.focus() & ref.blur()', () => {
-        let onFocus = () => {};
-        let spyOnFocus = sinon.spy(onFocus);
-        let props = {
-            autoFocus: true,
-        };
-        const R = getRating(props);
-        expect(document.activeElement.tagName).toEqual('UL');
-        R.instance().blur();
-        expect(document.activeElement.tagName).toEqual('BODY');
-        R.instance().focus();
-        expect(document.activeElement.tagName).toEqual('UL');
-    });
-
     it('onKeyDown', () => {
         let onKeyDown = () => {};
         let spyOnKeydown = sinon.spy(onKeyDown);
@@ -196,11 +182,9 @@ describe('Rating', () => {
         };
         const R = getRating(props);
         let ul = R.find('ul');
-        let keyCodeLeft = 37;
-        let keyCodeRight = 39;
-        ul.simulate('keyDown', { keyCode: keyCodeLeft });
+        ul.simulate('keyDown', { key: 'ArrowLeft' });
         expect(R.state().value).toEqual(1);
-        ul.simulate('keyDown', { keyCode: keyCodeRight });
+        ul.simulate('keyDown', { key: 'ArrowRight' });
         expect(R.state().value).toEqual(2);
         expect(spyOnKeydown.callCount).toEqual(2);
         let allowHalfProps = {
@@ -209,10 +193,10 @@ describe('Rating', () => {
         };
         const HalfR = getRating(allowHalfProps);
         let halfUl = HalfR.find('ul');
-        halfUl.simulate('keyDown', { keyCode: keyCodeLeft });
+        halfUl.simulate('keyDown', { key: 'ArrowLeft' });
         expect(HalfR.state().value).toEqual(2);
-        halfUl.simulate('keyDown', { keyCode: keyCodeRight });
-        halfUl.simulate('keyDown', { keyCode: keyCodeRight });
+        halfUl.simulate('keyDown', { key: 'ArrowRight' });
+        halfUl.simulate('keyDown', { key: 'ArrowRight' });
         expect(HalfR.state().value).toEqual(3);
     });
 
@@ -227,12 +211,10 @@ describe('Rating', () => {
         };
         const RWithWrapper = mount(<ConfigProvider direction='rtl'><Rating {...props}/></ConfigProvider>);
         let ul = RWithWrapper.find('ul');
-        let keyCodeLeft = 37;
-        let keyCodeRight = 39;
-        ul.simulate('keyDown', { keyCode: keyCodeLeft });
+        ul.simulate('keyDown', { key: 'ArrowLeft' });
         let R = RWithWrapper.find(Rating);
         expect(R.state().value).toEqual(3);
-        ul.simulate('keyDown', { keyCode: keyCodeRight });
+        ul.simulate('keyDown', { key: 'ArrowRight' });
         expect(R.state().value).toEqual(2);
         // allowHalf
         let allowHalfProps = {
@@ -244,10 +226,10 @@ describe('Rating', () => {
         let HalfR = HalfRWithWrapper.find(Rating);
         let stars = HalfR.find('div[role="radio"]');
 
-        halfUl.simulate('keyDown', { keyCode: keyCodeLeft });
+        halfUl.simulate('keyDown', { key: 'ArrowLeft' });
         expect(HalfR.state().value).toEqual(3);
-        halfUl.simulate('keyDown', { keyCode: keyCodeRight });
-        halfUl.simulate('keyDown', { keyCode: keyCodeRight });
+        halfUl.simulate('keyDown', { key: 'ArrowRight' });
+        halfUl.simulate('keyDown', { key: 'ArrowRight' });
         expect(HalfR.state().value).toEqual(2);
     })
 

+ 13 - 2
packages/semi-ui/rating/_story/rating.stories.js

@@ -1,5 +1,6 @@
 import React from 'react';
 import Rating from '../index';
+import Button from '../../button'
 import { IconLikeHeart } from '@douyinfe/semi-icons';
 
 export default {
@@ -25,7 +26,7 @@ export const _Rating = () => (
       <Rating allowClear={false} />
       <br />
       <h5>character</h5>
-      <Rating character={<IconLikeHeart />} />
+      <Rating size="small" character={<IconLikeHeart />} />
       <br />
       <Rating character={'好'} defaultValue={2} disabled />
     </div>
@@ -72,7 +73,7 @@ class Demo extends React.Component {
     const desc = ['terrible', 'bad', 'normal', 'good', 'wonderful'];
     return (
       <div>
-        <span>How was the help you received: {value ? <span>{desc[value - 1]}</span> : ''}</span>
+        <span>How was the help you received: {value ? <span id='rating-result'>{desc[value - 1]}</span> : ''}</span>
         <br />
         <Rating tooltips={desc} onChange={this.handleChange} value={value} />
       </div>
@@ -95,3 +96,13 @@ export const Keydown = () => <KeyDownDemo />;
 Keydown.story = {
   name: 'keydown',
 };
+
+const AutoFocusDemo = () => {
+  return <Rating defaultValue={2} autoFocus />;
+}
+
+export const AutoFocus = () => <AutoFocusDemo />;
+
+AutoFocus.story = {
+  name: 'autofocus',
+};

+ 59 - 16
packages/semi-ui/rating/index.tsx

@@ -48,6 +48,7 @@ export interface RatingState {
     hoverValue: number;
     focused: boolean;
     clearedValue: number;
+    emptyStarFocusVisible: boolean;
 }
 
 export default class Rating extends BaseComponent<RatingProps, RatingState> {
@@ -90,7 +91,7 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
         prefixCls: cssClasses.PREFIX,
         onChange: noop,
         onHoverChange: noop,
-        tabIndex: 0,
+        tabIndex: -1,
         size: 'default' as const,
     };
 
@@ -108,6 +109,7 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
             focused: false,
             hoverValue: undefined,
             clearedValue: null,
+            emptyStarFocusVisible: false,
         };
 
         this.foundation = new RatingFoundation(this.adapter);
@@ -127,9 +129,11 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
         return {
             ...super.adapter,
             focus: () => {
-                const { disabled } = this.props;
+                const { disabled, count } = this.props;
+                const { value } = this.state;
                 if (!disabled) {
-                    this.rate.focus();
+                    const index = Math.ceil(value) - 1;
+                    this.stars[index < 0 ? count : index].starFocus();
                 }
             },
             getStarDOM: (index: number) => {
@@ -180,6 +184,11 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
                 });
                 onKeyDown && onKeyDown(e);
             },
+            setEmptyStarFocusVisible: (focusVisible: boolean): void => {
+                this.setState({
+                    emptyStarFocusVisible: focusVisible, 
+                });
+            },
         };
     }
 
@@ -238,11 +247,31 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
         this.rate = node;
     };
 
-    render() {
-        const { count, allowHalf, style, prefixCls, disabled, className, character, tabIndex, size, tooltips, id } =
-            this.props;
+    handleStarFocusVisible = (event: React.FocusEvent) => {
+        this.foundation.handleStarFocusVisible(event);
+    }
+
+    handleStarBlur = (event: React.FocusEvent) => {
+        this.foundation.handleStarBlur(event);
+    }
+
+    getAriaLabelPrefix = () => {
+        if (this.props['aria-label']) {
+            return this.props['aria-label'];
+        }
+        let prefix = 'star';
+        const { character } = this.props;
+        if (typeof character === 'string') {
+            prefix = character;
+        }
+        return prefix;
+    }
+
+    getItemList = (ariaLabelPrefix: string) => {
+        const { count, allowHalf, prefixCls, disabled,  character, size, tooltips } =this.props;
         const { value, hoverValue, focused } = this.state;
-        const itemList = [...Array(count).keys()].map(ind => {
+        // index == count is for Empty rating
+        const itemList = [...Array(count + 1).keys()].map(ind => {
             const content = (
                 <Item
                     ref={this.saveRef(ind)}
@@ -251,13 +280,16 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
                     prefixCls={`${prefixCls}-star`}
                     allowHalf={allowHalf}
                     value={hoverValue === undefined ? value : hoverValue}
-                    onClick={this.onClick}
-                    onHover={this.onHover}
+                    onClick={disabled ? noop : this.onClick}
+                    onHover={disabled ? noop : this.onHover}
                     key={ind}
                     disabled={disabled}
                     character={character}
                     focused={focused}
-                    size={size}
+                    size={ind === count ? 0 : size}
+                    ariaLabelPrefix={ariaLabelPrefix}
+                    onFocus={disabled || count !== ind ? noop : this.handleStarFocusVisible}
+                    onBlur={disabled || count !== ind ? noop : this.handleStarBlur}
                 />
             );
             if (tooltips) {
@@ -271,25 +303,36 @@ export default class Rating extends BaseComponent<RatingProps, RatingState> {
             }
             return content;
         });
+        return itemList;
+    }
+
+    render() {
+        const { style, prefixCls, disabled, className, id, count, tabIndex } = this.props;
+        const { value, emptyStarFocusVisible } = this.state;
+        const ariaLabelPrefix = this.getAriaLabelPrefix();
+        const ariaLabel = `Rating: ${value} of ${count} ${ariaLabelPrefix}${value === 1 ? '' : 's'},`;
+        const itemList = this.getItemList(ariaLabelPrefix);
         const listCls = cls(
             prefixCls,
             {
                 [`${prefixCls}-disabled`]: disabled,
+                [`${prefixCls}-focus`]: emptyStarFocusVisible,
             },
             className
         );
         return (
-            <ul
-                aria-label={this.props['aria-label']}
+            // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+            <ul 
+                aria-label={ariaLabel}
                 aria-labelledby={this.props['aria-labelledby']}
                 aria-describedby={this.props['aria-describedby']}
                 className={listCls}
                 style={style}
-                onMouseLeave={disabled ? null : this.onMouseLeave}
+                onMouseLeave={disabled ? noop : this.onMouseLeave}
                 tabIndex={disabled ? -1 : tabIndex}
-                onFocus={disabled ? null : this.onFocus}
-                onBlur={disabled ? null : this.onBlur}
-                onKeyDown={disabled ? null : this.onKeyDown}
+                onFocus={disabled ? noop : this.onFocus}
+                onBlur={disabled ? noop : this.onBlur}
+                onKeyDown={disabled ? noop : this.onKeyDown}
                 ref={this.saveRate as any}
                 id={id}
             >

+ 139 - 26
packages/semi-ui/rating/item.tsx

@@ -1,14 +1,17 @@
 import React, { PureComponent } from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
-import { strings } from '@douyinfe/semi-foundation/rating/constants';
+import { cssClasses, strings } from '@douyinfe/semi-foundation/rating/constants';
 import '@douyinfe/semi-foundation/rating/rating.scss';
 import { IconStar } from '@douyinfe/semi-icons';
+import { RatingItemFoundation, RatingItemAdapter } from '@douyinfe/semi-foundation/rating/foundation';
+import BaseComponent, { BaseProps } from '../_base/baseComponent';
+
 
 type ArrayElement<ArrayType extends readonly unknown[]> =
   ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
 
-export interface RatingItemProps {
+export interface RatingItemProps extends BaseProps {
     value: number;
     index: number;
     prefixCls: string;
@@ -19,11 +22,19 @@ export interface RatingItemProps {
     focused: boolean;
     disabled: boolean;
     count: number;
+    ariaLabelPrefix: string;
     size: number | ArrayElement<typeof strings.SIZE_SET>;
     'aria-describedby'?: React.AriaAttributes['aria-describedby'];
+    onFocus?: (e: React.FocusEvent) => void;
+    onBlur?: (e: React.FocusEvent) => void;
+}
+
+export interface RatingItemState {
+    firstStarFocus: boolean,
+    secondStarFocus: boolean,
 }
 
-export default class Item extends PureComponent<RatingItemProps> {
+export default class Item extends BaseComponent<RatingItemProps, RatingItemState>  {
     static propTypes = {
         value: PropTypes.number,
         index: PropTypes.number,
@@ -35,13 +46,46 @@ export default class Item extends PureComponent<RatingItemProps> {
         focused: PropTypes.bool,
         disabled: PropTypes.bool,
         count: PropTypes.number,
+        ariaLabelPrefix: PropTypes.string,
         size: PropTypes.oneOfType([
             PropTypes.oneOf(strings.SIZE_SET),
             PropTypes.number,
         ]),
         'aria-describedby': PropTypes.string,
+        onFocus: PropTypes.func,
+        onBlur: PropTypes.func,
     };
 
+    foundation: RatingItemFoundation;
+
+    constructor(props: RatingItemProps) {
+        super(props);
+        this.state = {
+            firstStarFocus: false,
+            secondStarFocus: false,
+        };
+        this.foundation = new RatingItemFoundation(this.adapter);
+    }
+
+    get adapter(): RatingItemAdapter<RatingItemProps, RatingItemState> {
+        return {
+            ...super.adapter,
+            setFirstStarFocus: (value) => {
+                this.setState({
+                    firstStarFocus: value,
+                });
+            },
+            setSecondStarFocus: (value) => {
+                this.setState({
+                    secondStarFocus: value,
+                });
+            }
+        };
+    }
+
+    firstStar: HTMLDivElement = null;
+    secondStar: HTMLDivElement = null;
+
     onHover: React.MouseEventHandler = e => {
         const { onHover, index } = this.props;
         onHover(e, index);
@@ -52,6 +96,19 @@ export default class Item extends PureComponent<RatingItemProps> {
         onClick(e, index);
     };
 
+    onFocus =  (e, star) => {
+        const { onFocus } = this.props;
+        onFocus && onFocus(e);
+        this.foundation.handleFocusVisible(e, star);
+    } 
+
+    onBlur =  (e, star) => {
+        const { onBlur } = this.props;
+        onBlur && onBlur(e);
+        this.foundation.handleBlur(e, star);
+    } 
+
+
     onKeyDown: React.KeyboardEventHandler = e => {
         const { onClick, index } = this.props;
         if (e.keyCode === 13) {
@@ -59,6 +116,23 @@ export default class Item extends PureComponent<RatingItemProps> {
         }
     };
 
+    starFocus = () => {
+        const { value, index } = this.props;
+        if (value - index === 0.5) {
+            this.firstStar.focus();
+        } else {
+            this.secondStar.focus();
+        }
+    }
+
+    saveFirstStar = (node: HTMLDivElement) => {
+        this.firstStar = node;
+    };
+
+    saveSecondStar = (node: HTMLDivElement) => {
+        this.secondStar = node;
+    };
+
     render() {
         const {
             index,
@@ -69,18 +143,18 @@ export default class Item extends PureComponent<RatingItemProps> {
             disabled,
             allowHalf,
             focused,
-            size
+            size,
+            ariaLabelPrefix,
         } = this.props;
+        const { firstStarFocus, secondStarFocus } = this.state;
         const starValue = index + 1;
         const diff = starValue - value;
-        const isFocused = value === 0 && index === 0 && focused;
         // const isHalf = allowHalf && value + 0.5 === starValue;
         const isHalf = allowHalf && diff < 1 && diff > 0;
-        const firstWidth = isHalf ? 1 - diff : 0.5;
+        const firstWidth = 1 - diff;
         const isFull = starValue <= value;
         const isCustomSize = typeof size === 'number';
         const starCls = cls(prefixCls, {
-            [`${prefixCls}-focused`]: isFocused,
             [`${prefixCls}-half`]: isHalf,
             [`${prefixCls}-full`]: isFull,
             [`${prefixCls}-${size}`]: !isCustomSize,
@@ -91,26 +165,65 @@ export default class Item extends PureComponent<RatingItemProps> {
             fontSize: size
         } : {};
         const iconSize = isCustomSize ? 'inherit' : (size === 'small' ? 'default' : 'extra-large');
-        const content = character ? character : <IconStar size={iconSize} />;
+        const content = character ? character : <IconStar size={iconSize} style={{ display: 'block' }}/>;
+        const isEmpty = index === count;
+        const starWrapCls = cls(`${prefixCls}-wrapper`,{
+            [`${prefixCls}-disabled`]: disabled,
+            [`${cssClasses.PREFIX}-focus`]: (firstStarFocus || secondStarFocus) && value !== 0,
+        });
+        const starWrapProps = {
+            onClick: disabled ? null : this.onClick ,
+            onKeyDown: disabled ? null : this.onKeyDown,
+            onMouseMove: disabled ? null : this.onHover,
+            className: starWrapCls,
+        };
+        const AriaSetSize = allowHalf ? count * 2 + 1 : count + 1;
+        const firstStarProps = {
+            ref: this.saveFirstStar as any,
+            role:"radio",
+            'aria-checked': value === index + 0.5,
+            'aria-posinset': 2 * index + 1,
+            'aria-setsize': AriaSetSize,
+            'aria-disabled': disabled,
+            'aria-label': `${index + 0.5} ${ariaLabelPrefix}s`,
+            'aria-labelledby': this.props['aria-describedby'],
+            'aria-describedby': this.props['aria-describedby'],
+            className: cls(`${prefixCls}-first`,`${cssClasses.PREFIX}-no-focus`),
+            tabIndex: !disabled && value === index + 0.5 ? 0 : -1,
+            onFocus: (e) => {
+                this.onFocus(e, 'first');
+            },
+            onBlur:(e) => {
+                this.onBlur(e, 'first');
+            },
+        };
+
+        const secondStarTabIndex = !disabled && ((value === index + 1) || (isEmpty && value === 0)) ? 0 : -1;
+        const secondStarProps = {
+            ref: this.saveSecondStar as any,
+            role:"radio",
+            'aria-checked': isEmpty ? value === 0 : value === index + 1,
+            'aria-posinset': allowHalf ? 2 * (index + 1) : index + 1,
+            'aria-setsize': AriaSetSize, 
+            'aria-disabled': disabled,
+            'aria-label': `${isEmpty ? 0 : index + 1} ${ariaLabelPrefix}${index === 0 ? '' : 's'}`,
+            'aria-labelledby': this.props['aria-describedby'],
+            'aria-describedby': this.props['aria-describedby'],
+            className: cls(`${prefixCls}-second`,`${cssClasses.PREFIX}-no-focus`),
+            tabIndex: secondStarTabIndex,
+            onFocus: (e) => {
+                this.onFocus(e, 'second');
+            },
+            onBlur:(e) => {
+                this.onBlur(e, 'second');
+            },
+        };
+       
         return (
-            <li className={starCls} style={{ ...sizeStyle }}>
-                <div
-                    onClick={disabled ? null : this.onClick}
-                    onKeyDown={disabled ? null : this.onKeyDown}
-                    onMouseMove={disabled ? null : this.onHover}
-                    role="radio"
-                    aria-checked={value > index ? 'true' : 'false'}
-                    aria-posinset={index + 1}
-                    aria-setsize={count}
-                    aria-disabled={disabled}
-                    aria-label={`Rating ${index + (isHalf ? 0.5 : 1)}`}
-                    aria-labelledby={this.props['aria-describedby']} // screen reader will read labelledby instead of describedby
-                    aria-describedby={this.props['aria-describedby']}
-                    tabIndex={0}
-                    className={`${prefixCls}-wrapper`}
-                >
-                    <div className={`${prefixCls}-first`} style={{ width: `${firstWidth * 100}%` }}>{content}</div>
-                    <div className={`${prefixCls}-second`} x-semi-prop="character">{content}</div>
+            <li className={starCls} style={{ ...sizeStyle }} key={index} >
+                <div {...(starWrapProps as any)}>
+                    {allowHalf && !isEmpty && <div {...firstStarProps} style={{ width: `${firstWidth * 100}%` }}>{content}</div>}
+                    <div {...secondStarProps} x-semi-prop="character">{content}</div>
                 </div>
             </li>
         );