浏览代码

feat(a11y): slider add focus & keyboard event #205 (#960)

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

* fix: code optimization
YyumeiZhang 3 年之前
父节点
当前提交
86f85a8f91

+ 4 - 0
.eslintrc.js

@@ -20,6 +20,8 @@ module.exports = {
             rules: {
                 // 因为历史原因,现有项目基本全部是4个空格
                 indent: ['error', 4, { 'SwitchCase': 1 }],
+                'comma-spacing': ["error", { "before": false, "after": true }],
+                'no-multi-spaces': ["error", { ignoreEOLComments: true }],
                 'react/display-name': 'off',
                 'react/jsx-indent': ['error', 4],
                 'react/jsx-indent-props': ['error', 4],
@@ -52,6 +54,8 @@ module.exports = {
             rules: {
                 // 因为历史原因,现有项目基本全部是4个空格
                 indent: 'off',
+                'comma-spacing': ["error", { "before": false, "after": true }],
+                'no-multi-spaces': ["error", { ignoreEOLComments: true }],
                 '@typescript-eslint/indent': ['error', 4],
                 'react/display-name': 'off',
                 'react/jsx-indent': ['error', 4],

+ 35 - 13
content/input/slider/index-en-US.md

@@ -75,7 +75,7 @@ class InputSlider extends React.Component {
                 <div style={{ width: 320, marginRight: 15 }}>
                     <Slider step={1} value={value} onChange={(value) => (this.getSliderValue(value))} ></Slider>
                 </div>
-                <InputNumber onChange={(v) => this.getSliderValue(v)} style={{width: 100}} value={value} min={0} max={100} />
+                <InputNumber onChange={(v) => this.getSliderValue(v)} style={{ width: 100 }} value={value} min={0} max={100} />
             </div>
         );
     }
@@ -119,11 +119,11 @@ import { Slider } from '@douyinfe/semi-ui';
         <br/>
         <br/>
         <div>Marks</div>
-        <Slider marks={{ 20: '20c', 40: '40c' }} defaultValue={[0, 100]} range={true} ></Slider>
+        <Slider marks={{ 20: '20°C', 40: '40°C' }} defaultValue={[0, 100]} range={true} tipFormatter={v => (`${v}°C`)} getAriaValueText={(value) => `${value}°C`}></Slider>
         <br/>
         <br/>
         <div>Inclued</div>
-        <Slider marks={{ 20: '20c', 40: '40c' }} included={false} defaultValue={[0, 100]} range={true}></Slider>
+        <Slider marks={{ 20: '20°C', 40: '40°C' }} included={false} defaultValue={[0, 100]} range={true} tipFormatter={v => (`${v}°C`)} getAriaValueText={(value) => `${value}°C`}></Slider>
     </div>
 );
 ```
@@ -216,23 +216,23 @@ import { Slider } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical verticalReverse></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical range defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical verticalReverse range defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
-            <Slider vertical range marks={{ 20: '20c', 40: '40c' }} step={10} defaultValue={[20, 60]}></Slider>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
+            <Slider vertical range marks={{ 20: '20°C', 40: '40°C' }} step={10} defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
-            <Slider vertical verticalReverse range marks={{ 20: '20c', 40: '40c' }} step={10} defaultValue={[20, 60]}></Slider>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
+            <Slider vertical verticalReverse range marks={{ 20: '20°C', 40: '40°C' }} step={10} defaultValue={[20, 60]}></Slider>
         </div>
     </div>
 );
@@ -242,6 +242,9 @@ import { Slider } from '@douyinfe/semi-ui';
 
 | Property       | Instructions                                                                               | type          | Default | Version | 
 | -------------- | ------------------------------------------------------------------------------------------ | ------------- | ------- |------ |
+| aria-label| [aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label) used to define a string that labels the current element. Use it in cases where a text label is not visible on the screen | string |-|-|
+| aria-labelledby | [aria-labelledby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby) attribute establishes relationships between objects and their label(s), and its value should be one or more element IDs, which refer to elements that have the text needed for labeling | string |-|-|
+| aria-valuetext| [aria-valuetext](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-valuetext) used to provide a user-friendly name for the current value of the slider | string |-|-|
 | defaultValue   | Default value                                                                              | number \| number[] | 0       |- |
 | disabled       | Disable slider                                                                             | boolean       | false   |- |
 | included       | Takes effect when `marks` is not null, true means containment and false means coordination | boolean       | true    |- |
@@ -259,7 +262,7 @@ import { Slider } from '@douyinfe/semi-ui';
 | verticalReverse | Vertical but reverse direction >=1.29.0| boolean | false |-|
 | onAfterChange  | Triggered when onmouseup is invoked, passed in current value as params                     | (value: number \| number[]) => void      | -       |- |
 | onChange       | Callback function when slider value changes                                                | (value: number \| number[]) => void      | -       |- |
-
+| getAriaValueText | Used to provide a user-friendly name for the current value of the slider, important for screen reader users,  The parameters value and index are the current slider value, order | (value: number, index?: number) => string |-|-|
 ## Accessibility
 
 ### ARIA
@@ -269,8 +272,27 @@ import { Slider } from '@douyinfe/semi-ui';
 - The slider element has the `aria-valuemin` property set to a decimal value representing the minimum allowed value of the slider.
 - The slider element has the `aria-valuemax` property set to a decimal value representing the maximum allowed value of the slider.
 - If the slider is vertically oriented, it has `aria-orientation` set to vertical.
-- If the value of `aria-valuenow` is not user-friendly, e.g., the day of the week is represented by a number, support setting API `aria-valuetext` property to a string that makes the slider value understandable, e.g., "Monday". And you can use API `getAriaValueText(value)` to specify `aria-valuetext`.
+- If the value of `aria-valuenow` is not user-friendly, e.g., the day of the week is represented by a number, support setting API `aria-valuetext` property to a string that makes the slider value understandable, e.g., "Monday". And you can use API `getAriaValueText(value, index)` to specify `aria-valuetext`.
 - Supporting API `aria-label` `aria-labelledby` to specify Slider label.
 
+### Keyboard and Focus
+
+- The slider of Slider can get the focus and display the prompt information of the current slider, and this information needs to be read by assistive technology.
+- When the user uses the `range` API, you can use `Tab` and `Shift` + `Tab` to switch the focus of the left and right sliders.
+- Keyboard users can use `Up Arrow` or `Right Arrow` to increase the slider value, `Down Arrow` or `Left Arrow` to decrease the slider value.
+- If you want the slider to change more than the step size, Slider supports 10*step changes:
+  - Windows users: `Page Up` for increasing, `Page Down` for decreasing;
+  - Mac users:`Fn` + `Up Arrow` for increasing, `Fn` + `Down Arrow` for decreasing;
+  - When the user uses the `range` property, the Page Up key of the previous slider is only supported until it meets the next slider, and then using the Page Up key on the previous slider will not respond. The same is true for the latter slider. After encountering, there is no response to the Page Down key.
+- To move the slider to the minimum value of the slider:
+  - Windows users: `Home`;
+  - Mac users: `Fn` + `left arrow`;
+  - When the user uses the `range` property, the `Home`(`Fn` + `left arrow`) button of the latter slider only supports until it meets the previous slider, and the `Home`(`Fn` + `left arrow`) button is unresponsive after the overlap.
+- To move the slider to the maximum value of the slider:
+  - Windows users: `End`;
+  - Mac users: `Fn` + `right arrow`;
+  - When the user uses the `range` property, the `End`(`Fn` + `right arrow`) key of the previous slider is only supported until it meets the next slider, and the `End`(`Fn` + `right arrow`) key is unresponsive after the overlap.
+
+
 ## Design Tokens
 <DesignToken/>

+ 37 - 14
content/input/slider/index.md

@@ -70,7 +70,7 @@ class InputSlider extends React.Component {
                 <div style={{ width: 320, marginRight: 15 }}>
                     <Slider step={1} value={value} onChange={(value) => (this.getSliderValue(value))} ></Slider>
                 </div>
-                <InputNumber onChange={(v) => this.getSliderValue(v)} style={{width: 100}} value={value} min={0} max={100} />
+                <InputNumber onChange={(v) => this.getSliderValue(v)} style={{ width: 100 }} value={value} min={0} max={100} />
             </div>
         );
     }
@@ -78,14 +78,14 @@ class InputSlider extends React.Component {
 ```
 
 ### 自定义提示
-使用 `tipFormatter` 可以设置 Tooltip 的显示的格式。设置 `tipFormatter={null}`,则隐藏 Tooltip。
+使用 `tipFormatter` 可以设置 Tooltip 的显示的格式。设置 `tipFormatter={null}`,则隐藏 Tooltip。`getAriaValueText`用于给滑块的当前值提供一个用户友好的名称,对屏幕阅读器用户很重要。
 ```jsx live=true
 import React from 'react';
 import { Slider } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <Slider tipFormatter={v => (`${v}%`)} />
+        <Slider tipFormatter={v => (`${v}%`)} getAriaValueText={v => (`${v}%`)}/>
         <br/>
         <br/>
         <Slider tipFormatter={null} />
@@ -110,11 +110,11 @@ import { Slider } from '@douyinfe/semi-ui';
         <br/>
         <br/>
         <div>Marks</div>
-        <Slider marks={{ 20: '20c', 40: '40c' }} defaultValue={[0, 100]} range={true} ></Slider>
+        <Slider marks={{ 20: '20°C', 40: '40°C' }} defaultValue={[0, 100]} tipFormatter={v => (`${v}°C`)} range={true} getAriaValueText={(value) => `${value}°C`}></Slider>
         <br/>
         <br/>
         <div>Inclued</div>
-        <Slider marks={{ 20: '20c', 40: '40c' }} included={false} defaultValue={[0, 100]} range={true}></Slider>
+        <Slider marks={{ 20: '20°C', 40: '40°C' }} included={false} defaultValue={[0, 100]} range={true} tipFormatter={v => (`${v}°C`)} getAriaValueText={(value) => `${value}°C`}></Slider>
     </div>
 );
 ```
@@ -203,23 +203,23 @@ import { Slider } from '@douyinfe/semi-ui';
 
 () => (
     <div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical verticalReverse></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical range defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
             <Slider vertical verticalReverse range defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
-            <Slider vertical range marks={{ 20: '20c', 40: '40c' }} step={10} defaultValue={[20, 60]}></Slider>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
+            <Slider vertical range marks={{ 20: '20°C', 40: '40°C' }} step={10} defaultValue={[20, 60]}></Slider>
         </div>
-        <div style={{height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block'}}>
-            <Slider vertical verticalReverse range marks={{ 20: '20c', 40: '40c' }} step={10} defaultValue={[20, 60]}></Slider>
+        <div style={{ height: 300, marginLeft: 30, marginTop: 10, paddingRight: 30, display: 'inline-block' }}>
+            <Slider vertical verticalReverse range marks={{ 20: '20°C', 40: '40°C' }} step={10} defaultValue={[20, 60]}></Slider>
         </div>
     </div>
 );
@@ -229,6 +229,9 @@ import { Slider } from '@douyinfe/semi-ui';
 ## API参考
 | 属性  | 说明        | 类型   | 默认值 | 版本 | 
 |-------|-------------|-----------------|--------|-------|
+| aria-label| [aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)属性,用来给当前元素加上的标签描述, 提升可访问性 | string |-|-|
+| aria-labelledby | [aria-labelledby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby)属性,表明某些元素的 id 是某一对象的标签。它被用来确定控件或控件组与它们标签之间的联系, 提升可访问性 | string |-|-|
+| aria-valuetext| [aria-valuetext](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-valuetext)属性,为滑块的当前值提供用户友好的名称。| string |-|-|
 | defaultValue | 设置初始取值 | number \| number[] | 0 |-|
 | disabled | 滑块是否禁用 | boolean | false |-|
 | included | `marks` 不为空对象时有效,值为 true 时表示值为包含关系,false 表示并列 | boolean | true |-|
@@ -246,6 +249,7 @@ import { Slider } from '@douyinfe/semi-ui';
 | verticalReverse | 反转垂直方向,即上大下小 >=1.29.0| boolean | false |-|
 | onAfterChange | 与 `onmouseup` 触发时机一致,把当前值作为参数传入 | (value: number \| number[]) => void | 无 |-|
 | onChange | 当 Slider 的值发生改变时的回调 | (value: number \| number[]) => void | 无 |-|
+| getAriaValueText | 用于给滑块的当前值提供一个用户友好的名称,对屏幕阅读器用户很重要,参数value为当前滑块的值,index为当前滑块的顺序 | (value: number, index?: number) => string |-|-|
 
 ## Accessibility
 
@@ -256,9 +260,28 @@ import { Slider } from '@douyinfe/semi-ui';
 - 元素的 `aria-valuemin` 属性为最小允许值的十进制数值。
 - 元素的 `aria-valuemax` 属性为最大允许值的十进制数值。
 - 当 Slider 为纵向时,元素的 `aria-orientation` 属性为 'vertical'。
-- 当 `aria-valuenow` 的值不容易被理解时,支持通过 API `aria-valuetext` 传递一个字符串使其更友好。也可以通过 API `geAriaValueText(value)` 方法得到 `aria-valuetext` 的值。
+- 当 `aria-valuenow` 的值不容易被理解时,支持通过 API `aria-valuetext` 传递一个字符串使其更友好。也可以通过 API `getAriaValueText(value, index)` 方法得到 `aria-valuetext` 的值。
 - 支持通过 API `aria-label` 或者 `aria-labelledby` 确定 slider 的标签。
 
+### 键盘和焦点
+
+- Slider 的滑块可被获取到焦点,并展示当前滑块的提示信息,且这些信息需要被辅助技术读取到。
+- 当用户使用 `range` 属性时,可以使用 `Tab` 及 `Shift`  + `Tab` 切换左右两个滑块的焦点。
+- 键盘用户可以通过 `上箭头` 或 `右箭头` 来增加滑块值,`下箭头` 或 `左箭头` 来减少滑块值。
+- 若想要滑块高于步长的变化量时, slider支持 10*step 的变化量:
+  - Windows 用户: `Page Up` 用于增加,`Page Down` 用于减少;
+  - Mac 用户使用: `Fn` + `上箭头` 用于增加,`Fn` + `下箭头` 用于按键;
+  - 当用户使用 `range` 属性时,前一个滑块的  `Page Up`(`Fn` + `上箭头`) 键仅支持到与后一个滑块相遇,重合后再对前一个滑块使用  Page Up 键则无响应。后一个滑块同理,相遇后,对`Page Down`(`Fn` + `下箭头`) 键无响应。
+- 若想将滑块移动到滑杆的最小值处:
+  - Windows 用户: `Home` ;
+  - Mac 用户: `Fn` + `左箭头`;
+  - 当用户使用 `range` 属性时,后一个滑块的 `Home`(`Fn` + `左箭头`) 键仅支持到与前一个滑块相遇,重合后再次使用 `Home`(`Fn` + `左箭头`) 键无响应。
+- 若想将滑块移动到滑杆的最大值处:
+  - Windows 用户:`End` ;
+  - Mac 用户:`Fn` + `右箭头`;
+  - 当用户使用 `range` 属性时,前一个滑块的 `End`(`Fn` + `右箭头`) 键仅支持到与后一个滑块相遇,重合后再次使用 `End`(`Fn` + `右箭头`) 键无响应。
+
+
 
 ## 设计变量
 <DesignToken/>

+ 65 - 0
cypress/integration/slider.spec.js

@@ -141,4 +141,69 @@ describe('slider', () => {
             expect($button.position()).deep.equal(handleInitialPos);
         });
     });
+
+    it('keyboard', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=slider--horizontal-slider&args=&viewMode=story');
+        cy.get('.semi-slider-handle').eq(0).click();
+        // test keyboard event: upArrow
+        cy.get('.semi-slider-handle').eq(0).type('{upArrow}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '1');
+        // test keyboard event: rightArrow
+        cy.get('.semi-slider-handle').eq(0).type('{rightArrow}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '2');
+        // test keyboard event: downArrow
+        cy.get('.semi-slider-handle').eq(0).type('{downArrow}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '1');
+        // test keyboard event: leftArrow
+        cy.get('.semi-slider-handle').eq(0).type('{leftArrow}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '0');
+        // test keyboard event: pageup
+        cy.get('.semi-slider-handle').eq(0).type('{pageup}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '10');
+        // test keyboard event: pagedown
+        cy.get('.semi-slider-handle').eq(0).type('{pagedown}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '0');
+        // test keyboard event: End
+        cy.get('.semi-slider-handle').eq(0).type('{end}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '100');
+        // test keyboard event: Home
+        cy.get('.semi-slider-handle').eq(0).type('{home}');
+        cy.get('.semi-slider-handle').eq(0).should('have.attr', 'aria-valuenow', '0');
+        // test keyboard event: tab
+        cy.get('.semi-slider-handle').eq(0).tab();
+        cy.get('.semi-slider-handle').eq(1).should('be.focused');
+    });
+
+    it('range', () => {
+        cy.visit('http://127.0.0.1:6006/iframe.html?id=slider--horizontal-slider&args=&viewMode=story');
+        // click range slider right dot
+        cy.get('.semi-slider-handle').eq(2).click();
+        cy.get('.semi-slider-handle').eq(2).type('{pageup}').type('{pageup}').type('{pageup}').type('{pageup}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '60');
+        // The value of the left slider cannot exceed the value of the right slider
+        cy.get('.semi-slider-handle').eq(2).type('{pageup}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '60');
+        cy.get('.semi-slider-handle').eq(2).type('{rightArrow}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '60');
+        cy.get('.semi-slider-handle').eq(2).type('{End}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '60');
+        // The value of the right slider cannot be lower than the value of the left slider
+        cy.get('.semi-slider-handle').eq(2).tab();
+        cy.get('.semi-slider-handle').eq(3).type('{pagedown}');
+        cy.get('.semi-slider-handle').eq(3).should('have.attr', 'aria-valuenow', '60');
+        cy.get('.semi-slider-handle').eq(3).type('{leftArrow}');
+        cy.get('.semi-slider-handle').eq(3).should('have.attr', 'aria-valuenow', '60');
+        cy.get('.semi-slider-handle').eq(3).type('{Home}');
+        cy.get('.semi-slider-handle').eq(3).should('have.attr', 'aria-valuenow', '60');
+        cy.get('.semi-slider-handle').eq(3).type('{pageup}');
+        cy.get('.semi-slider-handle').eq(3).should('have.attr', 'aria-valuenow', '70');
+        cy.get('.semi-slider-handle').eq(3).type('{End}');
+        cy.get('.semi-slider-handle').eq(3).should('have.attr', 'aria-valuenow', '100');
+
+        cy.get('.semi-slider-handle').eq(2).click();
+        cy.get('.semi-slider-handle').eq(2).type('{pagedown}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '50');
+        cy.get('.semi-slider-handle').eq(2).type('{Home}');
+        cy.get('.semi-slider-handle').eq(2).should('have.attr', 'aria-valuenow', '0');
+    });  
 });

+ 141 - 9
packages/semi-foundation/slider/foundation.ts

@@ -3,7 +3,8 @@
 /* eslint-disable no-nested-ternary */
 import BaseFoundation, { DefaultAdapter } from '../base/foundation';
 import touchEventPolyfill from '../utils/touchPolyfill';
-
+import warning from '../utils/warning';
+import { handlePrevent } from '../utils/a11y';
 
 export interface Marks{
     [key: number]: string;
@@ -34,7 +35,7 @@ export interface SliderProps{
     'aria-label'?: string;
     'aria-labelledby'?: string;
     'aria-valuetext'?: string;
-    getAriaValueText?: (value: number) => string;
+    getAriaValueText?: (value: number, index?: number) => string;
 }
 
 export interface SliderState {
@@ -49,6 +50,8 @@ export interface SliderState {
     clickValue: 0;
     showBoundary: boolean;
     isInRenderTree: boolean;
+    firstDotFocusVisible: boolean;
+    secondDotFocusVisible: boolean;
 }
 
 export interface SliderLengths{
@@ -65,7 +68,6 @@ export interface ScrollParentVal{
 
 export interface OverallVars{
     dragging: boolean[];
-    chooseMovePos: 'min' | 'max';
 }
 
 export interface SliderAdapter extends DefaultAdapter<SliderProps, SliderState>{
@@ -514,8 +516,7 @@ export default class SliderFoundation extends BaseFoundation<SliderAdapter> {
         const handleMinDom = this._adapter.getMinHandleEl().current;
         const handleMaxDom = this._adapter.getMaxHandleEl().current;
         if (e.target === handleMinDom || e.target === handleMaxDom) {
-            e.preventDefault();
-            e.stopPropagation();
+            handlePrevent(e);
             const touch = touchEventPolyfill(e.touches[0], e);
             this.onHandleDown(touch, handler);
         }
@@ -564,14 +565,147 @@ export default class SliderFoundation extends BaseFoundation<SliderAdapter> {
             this._adapter.setDragging([dragging[0], false]);
         }
         this._adapter.setStateVal('isDrag', false);
-        // this._adapter.setStateVal('chooseMovePos', '');
         this._adapter.onHandleLeave();
         this._adapter.onHandleUpAfter();
         return true;
     };
 
+    _handleValueDecreaseWithKeyBoard = (step: number, handler: 'min'| 'max') => {
+        const { min, currentValue } = this.getStates();
+        const { range } = this.getProps();
+        if (handler === 'min') {
+            if (range) {
+                let newMinValue = currentValue[0] - step;
+                newMinValue = newMinValue < min ? min : newMinValue;
+                return [newMinValue, currentValue[1]];
+            } else {
+                let newMinValue = currentValue - step;
+                newMinValue = newMinValue < min ? min : newMinValue;
+                return newMinValue;
+            }
+        } else {
+            let newMaxValue = currentValue[1] - step;
+            newMaxValue = newMaxValue < currentValue[0] ? currentValue[0] : newMaxValue;
+            return [currentValue[0], newMaxValue];
+        }
+    }
+
+    _handleValueIncreaseWithKeyBoard = (step: number, handler: 'min'| 'max') => {
+        const { max, currentValue } = this.getStates();
+        const { range } = this.getProps();
+        if (handler === 'min') {
+            if (range) {
+                let newMinValue = currentValue[0] + step;
+                newMinValue = newMinValue > currentValue[1] ? currentValue[1] : newMinValue;
+                return [newMinValue, currentValue[1]];
+            } else {
+                let newMinValue = currentValue + step;
+                newMinValue = newMinValue > max ? max : newMinValue;
+                return newMinValue;
+            }
+        } else {
+            let newMaxValue = currentValue[1] + step;
+            newMaxValue = newMaxValue > max ? max : newMaxValue;
+            return [currentValue[0], newMaxValue];
+        }
+    }
+
+    _handleHomeKey = (handler: 'min'| 'max') => {
+        const { min, currentValue } = this.getStates();
+        const { range } = this.getProps();
+        if (handler === 'min') {
+            if (range) {
+                return [min, currentValue[1]];
+            } else {
+                return min;
+            }
+        } else {
+            return [currentValue[0], currentValue[0]];
+        }
+    }
+
+    _handleEndKey = (handler: 'min'| 'max') => {
+        const { max, currentValue } = this.getStates();
+        const { range } = this.getProps();
+        if (handler === 'min') {
+            if (range) {
+                return [currentValue[1], currentValue[1]];
+            } else {
+                return max;
+            }
+        } else {
+            return [currentValue[0], max];
+        }
+    }
+
+    handleKeyDown = (event: any, handler: 'min'| 'max') => {
+        const { min, max, currentValue } = this.getStates();
+        const { step, range } = this.getProps();
+        let outputValue;
+        switch (event.key) {
+            case "ArrowLeft":
+            case "ArrowDown":
+                outputValue = this._handleValueDecreaseWithKeyBoard(step, handler);
+                break;
+            case "ArrowRight":
+            case "ArrowUp":
+                outputValue = this._handleValueIncreaseWithKeyBoard(step, handler);
+                break;
+            case "PageUp":
+                outputValue = this._handleValueIncreaseWithKeyBoard(10 * step, handler);
+                break;
+            case "PageDown":
+                outputValue = this._handleValueDecreaseWithKeyBoard(10 * step, handler);
+                break;
+            case "Home":
+                outputValue = this._handleHomeKey(handler);
+                break;
+            case "End":
+                outputValue = this._handleEndKey(handler);
+                break;
+            case 'default':
+                break;
+        }
+        if (["ArrowLeft", "ArrowDown", "ArrowRight", "ArrowUp", "PageUp", "PageDown", "Home", "End"].includes(event.key)) {
+            let update = true;
+            if (Array.isArray(currentValue)) {
+                update = !(currentValue[0] === outputValue[0] && currentValue[1] === outputValue[1]);
+            } else {
+                update = currentValue !== outputValue;
+            }
+            if (update) {
+                this._adapter.updateCurrentValue(outputValue);
+                this._adapter.notifyChange(outputValue);
+            }
+            handlePrevent(event);
+        }
+    }
+
     // eslint-disable-next-line @typescript-eslint/no-empty-function
-    onFocus = (e:any, handler: 'min'| 'max') => {}
+    onFocus = (e: any, handler: 'min'| 'max') => {
+        handlePrevent(e);
+        const { target } = e;
+        try {
+            if (target.matches(':focus-visible')) {
+                if (handler === 'min') {
+                    this._adapter.setStateVal('firstDotFocusVisible', true);
+                } else {
+                    this._adapter.setStateVal('secondDotFocusVisible', true);
+                }
+            }
+        } catch (error) {
+            warning(true, 'Warning: [Semi Slider] The current browser does not support the focus-visible'); 
+        }
+    }
+
+    onBlur = (e: any, handler: 'min'| 'max') => {
+        const { firstDotFocusVisible, secondDotFocusVisible } = this.getStates();
+        if (handler === 'min') {
+            firstDotFocusVisible && this._adapter.setStateVal('firstDotFocusVisible', false);
+        } else {
+            secondDotFocusVisible && this._adapter.setStateVal('secondDotFocusVisible', false);
+        }
+    }
 
     handleWrapClick = (e: any) => {
         const { disabled, isDrag } = this._adapter.getStates();
@@ -648,6 +782,4 @@ export default class SliderFoundation extends BaseFoundation<SliderAdapter> {
         return vertical ? y : x;
     }
 
-
-
 }

+ 7 - 5
packages/semi-foundation/slider/slider.scss

@@ -1,5 +1,5 @@
 //@import '../theme/variables.scss';
-@import "./variables.scss";
+@import './variables.scss';
 
 $module: #{$prefix}-slider;
 
@@ -29,7 +29,7 @@ $module: #{$prefix}-slider;
         font-variant: tabular-nums;
         line-height: $font-slider_rail-lineHeight;
         list-style: none;
-        font-feature-settings: "tnum";
+        font-feature-settings: 'tnum';
         position: absolute;
         height: $height-slider_rail;
         cursor: pointer;
@@ -54,7 +54,11 @@ $module: #{$prefix}-slider;
         border: none;
         border-radius: 50%;
         cursor: pointer;
-        transition: #fff .3s;
+        transition: #fff 0.3s;
+
+        &:focus-visible {
+            outline: $width-slider_handle-focus solid $color-slider_handle-focus;
+        }
     }
 
     &-handle:hover {
@@ -133,10 +137,8 @@ $module: #{$prefix}-slider;
         text-align: center;
         cursor: pointer;
         transform: translate(-50%, 0) rotate(-180deg);
-
     }
 
-
     &-boundary {
         position: relative;
         font-size: $font-size-small;

+ 4 - 4
packages/semi-foundation/slider/variables.scss

@@ -11,9 +11,10 @@ $color-slider_handle_disabled-border-hover: var(--semi-color-white); // 禁用
 $color-slider_handle_disabled-border: var(--semi-color-border); // 禁用滑动条圆形描边颜色 - 默认态
 $color-slider_mark-text-default: var(--semi-color-text-2); // 滑动条刻度文字颜色
 $color-slider_rail-bg-default: var(--semi-color-fill-0); // 滑动条轨道颜色 - 未填充
-$color-slider_rail: rgba(0, 0, 0, .65);
+$color-slider_rail: rgba(0, 0, 0, 0.65);
 $color-slider_track-bg-default: var(--semi-color-primary); // 滑动条轨道颜色 - 已填充
 $color-slider_track_disabled-bg: var(--semi-color-primary-disabled); // 禁用滑动条轨道颜色 - 已填充
+$color-slider_handle-focus: var(--semi-color-primary-light-active); // 圆形按钮轮廓 - 聚焦
 
 // Spacing
 $spacing-slider-paddingX: 13px; // 滑动条整体水平内边距
@@ -33,7 +34,7 @@ $spacing-slider_boundary_min-left: 0;
 $spacing-slider_boundary_max-right: 0;
 $spacing-slider_vertical_marks-marginTop: -30px; // 垂直滑动条刻度标签顶部外边距
 $spacing-slider_vertical_marks-marginLeft: 29px; // 垂直滑动条刻度标签左侧外边距
-$spacing-slider_vertical_marks-reverse-marginLeft: -26px;// 垂直滑动条刻度标签左侧外边距(标签在左侧时)
+$spacing-slider_vertical_marks-reverse-marginLeft: -26px; // 垂直滑动条刻度标签左侧外边距(标签在左侧时)
 $spacing-slider_vertical_rail-top: 0; // 垂直滑动条轨道顶部距离
 $spacing-slider_vertical_handle-marginTop: 0; // 垂直滑动条原型按钮顶部外边距
 $spacing-slider_vertical_handle-marginLeft: -10px; // 垂直滑动条原型按钮左侧外边距
@@ -42,7 +43,6 @@ $spacing-slider_vertical_handle-marginLeft: -10px; // 垂直滑动条原型按
 $radius-slider_rail: var(--semi-border-radius-small); // 滚动条未填充轨道圆角
 $radius-slider_track: var(--semi-border-radius-small); // 滚动条已填充轨道圆角
 
-
 // Width/Height
 $height-slider_wrapper: 32px; // 滚动条容器整体高度
 $height-slider_vertical_wrapper: 4px; // 垂直滚动条整体宽度
@@ -52,7 +52,7 @@ $width-slider_handle_clicked: 1px; // 滚动条圆形按钮按下后描边宽度
 $height-slider_track: 4px; // 滚动条已填充轨道高度
 $width-slider_dot: 4px; // 滚动条圆形刻度点宽度
 $width-slider_handle_border_disabled: 1px; // 禁用滚动条圆形按钮按下后描边宽度
-
+$width-slider_handle-focus: 2px; // 圆形按钮轮廓 - 聚焦
 
 // Font
 $font-slider_rail-fontSize: 14px; // 滚动条轨道文本字号

+ 2 - 2
packages/semi-foundation/utils/a11y.ts

@@ -1,6 +1,6 @@
 import { get } from "lodash";
 
-export function handlePrevent(event: any)  {
+export function handlePrevent(event: any) {
     event.stopPropagation();
     event.preventDefault();
 }
@@ -47,7 +47,7 @@ export function setFocusToPreviousMenuItem (itemNodes: HTMLElement[], currentIte
 }
 
 // set focus to the next item in item list
-export function  setFocusToNextMenuitem (itemNodes: HTMLElement[], currentItem: HTMLElement): void {
+export function setFocusToNextMenuitem (itemNodes: HTMLElement[], currentItem: HTMLElement): void {
     let newMenuItem: HTMLElement, index: number;
 
     if (itemNodes.length > 0){

+ 4 - 2
packages/semi-ui/slider/_story/slider.stories.js

@@ -113,7 +113,8 @@ export const HorizontalSlider = () => (
     <div style={divStyle}>
       <div>marks</div>
       <Slider
-        marks={{ 20: '20c', 40: '40c' }}
+        marks={{ 20: '20°C', 40: '40°C' }}
+        getAriaValueText={(value) => `${value}°C`}
         defaultValue={[0, 100]}
         range={true}
         onChange={value => {
@@ -124,7 +125,8 @@ export const HorizontalSlider = () => (
     <div style={divStyle}>
       <div>inclued</div>
       <Slider
-        marks={{ 20: '20c', 40: '40c' }}
+        marks={{ 20: '20°C', 40: '40°C' }}
+        getAriaValueText={(value) => `${value}°C`}
         included={false}
         defaultValue={[0, 100]}
         range={true}

+ 63 - 33
packages/semi-ui/slider/index.tsx

@@ -51,6 +51,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
         showBoundary: PropTypes.bool,
         railStyle: PropTypes.object,
         verticalReverse: PropTypes.bool,
+        getAriaValueText: PropTypes.func,
     } as any;
 
     static defaultProps: Partial<SliderProps> = {
@@ -77,7 +78,6 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
     private maxHanleEl: React.RefObject<HTMLDivElement>;
     private dragging: boolean[];
     private eventListenerSet: Set<() => void>;
-    private chooseMovePos: 'min' | 'max';
     foundation: SliderFoundation;
 
     constructor(props: SliderProps) {
@@ -98,14 +98,14 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
             isDrag: false,
             clickValue: 0,
             showBoundary: false,
-            isInRenderTree: true
+            isInRenderTree: true,
+            firstDotFocusVisible: false,
+            secondDotFocusVisible: false,
         };
         this.sliderEl = React.createRef();
         this.minHanleEl = React.createRef();
         this.maxHanleEl = React.createRef();
         this.dragging = [false, false];
-        // this.chooseMovePos = 'min';
-        // this.isDrag = false;
         this.foundation = new SliderFoundation(this.adapter);
         this.eventListenerSet = new Set();
     }
@@ -165,7 +165,6 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
             },
             getOverallVars: () => ({
                 dragging: this.dragging,
-                chooseMovePos: this.chooseMovePos,
             }),
             updateDisabled: (disabled: boolean) => {
                 this.setState({ disabled });
@@ -189,8 +188,6 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
             getMinHandleEl: () => this.minHanleEl,
             getMaxHandleEl: () => this.maxHanleEl,
             onHandleDown: (e: React.MouseEvent) => {
-                e.stopPropagation();
-                e.preventDefault();
                 this._addEventListener(document.body, 'mousemove', this.foundation.onHandleMove, false);
                 this._addEventListener(document.body, 'mouseup', this.foundation.onHandleUp, false);
                 this._addEventListener(document.body, 'touchmove', this.foundation.onHandleTouchMove, false);
@@ -287,7 +284,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
 
     renderHandle = () => {
         const { vertical, range, tooltipVisible, tipFormatter, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-valuetext': ariaValueText, getAriaValueText, disabled } = this.props;
-        const { chooseMovePos, isDrag, isInRenderTree } = this.state;
+        const { chooseMovePos, isDrag, isInRenderTree, firstDotFocusVisible, secondDotFocusVisible } = this.state;
         const stylePos = vertical ? 'top' : 'left';
         const percentInfo = this.foundation.getMinAndMaxPercent(this.state.currentValue);
         const minPercent = percentInfo.min;
@@ -307,7 +304,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
         const { min, max, currentValue } = this.state;
 
         const commonAria = {
-            'aria-label': ariaLabel,
+            'aria-label': ariaLabel ?? (disabled ? 'Disabled Slider' : undefined),
             'aria-labelledby': ariaLabelledby,
             'aria-disabled': disabled
         };
@@ -319,7 +316,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                 position="top"
                 trigger="custom"
                 rePosKey={minPercent}
-                visible={isInRenderTree && tipVisible.min}
+                visible={isInRenderTree && (tipVisible.min || firstDotFocusVisible)}
                 className={`${cssClasses.HANDLE}-tooltip`}
             >
                 <span
@@ -352,24 +349,32 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                     onTouchEnd={e => {
                         this.foundation.onHandleUp(e);
                     }}
-                    onFocus={e => this.foundation.onFocus(e, 'min')}
+                    onKeyDown={(e)=>{
+                        this.foundation.handleKeyDown(e, 'min');
+                    }}
+                    onFocus={e => {
+                        this.foundation.onFocus(e, 'min');
+                    }}
+                    onBlur={(e) => { 
+                        this.foundation.onBlur(e, 'min');
+                    }}
                     role="slider"
-                    tabIndex={0}
+                    aria-valuetext={getAriaValueText ? getAriaValueText(currentValue as number, 0) : ariaValueText}
+                    tabIndex={disabled ? -1 : 0}
                     {...commonAria}
                     aria-valuenow={currentValue as number}
                     aria-valuemax={max}
                     aria-valuemin={min}
-                    aria-valuetext={getAriaValueText ? getAriaValueText(currentValue as number) : ariaValueText}
                 />
             </Tooltip>
         ) : (
             <React.Fragment>
-                <Tooltip
+                <Tooltip    
                     content={tipChildren.min}
                     position="top"
                     trigger="custom"
                     rePosKey={minPercent}
-                    visible={isInRenderTree && tipVisible.min}
+                    visible={isInRenderTree && (tipVisible.min || firstDotFocusVisible)}
                     className={`${cssClasses.HANDLE}-tooltip`}
                 >
                     <span
@@ -401,12 +406,20 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                         onTouchEnd={e => {
                             this.foundation.onHandleUp(e);
                         }}
-                        onFocus={e => this.foundation.onFocus(e, 'min')}
+                        onKeyDown={(e)=>{
+                            this.foundation.handleKeyDown(e, 'min');
+                        }}
+                        onFocus={e => {
+                            this.foundation.onFocus(e, 'min');
+                        }}
+                        onBlur={(e) => { 
+                            this.foundation.onBlur(e, 'min');
+                        }}
                         role="slider"
-                        tabIndex={0}
+                        tabIndex={disabled ? -1 : 0}
                         {...commonAria}
+                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[0], 0) : ariaValueText}
                         aria-valuenow={currentValue[0]}
-                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[0]) : ariaValueText}
                         aria-valuemax={currentValue[1]}
                         aria-valuemin={min}
                     />
@@ -416,7 +429,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                     position="top"
                     trigger="custom"
                     rePosKey={maxPercent}
-                    visible={isInRenderTree && tipVisible.max}
+                    visible={isInRenderTree && (tipVisible.max || secondDotFocusVisible)}
                     className={`${cssClasses.HANDLE}-tooltip`}
                 >
                     <span
@@ -448,12 +461,20 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                         onTouchEnd={e => {
                             this.foundation.onHandleUp(e);
                         }}
-                        onFocus={e => this.foundation.onFocus(e, 'min')}
+                        onKeyDown={e =>{
+                            this.foundation.handleKeyDown(e, 'max');
+                        }}
+                        onFocus={e =>  {
+                            this.foundation.onFocus(e, 'max');
+                        }}
+                        onBlur={(e) => {
+                            this.foundation.onBlur(e, 'max');
+                        }}
                         role="slider"
-                        tabIndex={0}
+                        tabIndex={disabled ? -1 : 0}
                         {...commonAria}
+                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[1], 1) : ariaValueText}
                         aria-valuenow={currentValue[1]}
-                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[1]) : ariaValueText}
                         aria-valuemax={max}
                         aria-valuemin={currentValue[0]}
                     />
@@ -538,29 +559,38 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
         return labelContent;
     };
 
+    _getAriaValueText = (value: number, index?: number) => {
+        const { getAriaValueText } = this.props;
+        return getAriaValueText ? getAriaValueText(value, index) : value;
+    }
+
 
     render() {
+        const { disabled, currentValue, min, max  } = this.state;
+        const { vertical, verticalReverse, style, railStyle, range, className } = this.props;
         const wrapperClass = cls(
             `${prefixCls}-wrapper`,
             {
-                [`${prefixCls}-disabled`]: this.state.disabled,
-                [`${cssClasses.VERTICAL}-wrapper`]: this.props.vertical,
-                [`${prefixCls}-reverse`]: this.props.vertical && this.props.verticalReverse
+                [`${prefixCls}-disabled`]: disabled,
+                [`${cssClasses.VERTICAL}-wrapper`]: vertical,
+                [`${prefixCls}-reverse`]: vertical && verticalReverse
             },
-            this.props.className
+            className
         );
         const boundaryClass = cls(`${prefixCls}-boundary`, {
             [`${prefixCls}-boundary-show`]: this.props.showBoundary && this.state.showBoundary,
         });
         const sliderCls = cls({
-            [`${prefixCls}`]: !this.props.vertical,
-            [cssClasses.VERTICAL]: this.props.vertical,
+            [`${prefixCls}`]: !vertical,
+            [cssClasses.VERTICAL]: vertical,
         });
+        const ariaLabel = range ? `Range: ${this._getAriaValueText(currentValue[0], 0)} to ${this._getAriaValueText(currentValue[1], 1)}` : undefined;
         const slider = (
             <div
                 className={wrapperClass}
-                style={this.props.style}
+                style={style}
                 ref={this.sliderEl}
+                aria-label={ariaLabel}
                 onMouseEnter={() => this.foundation.handleWrapperEnter()}
                 onMouseLeave={() => this.foundation.handleWrapperLeave()}
             >
@@ -568,7 +598,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                     <div
                         className={`${prefixCls}-rail`}
                         onClick={this.foundation.handleWrapClick}
-                        style={this.props.railStyle}
+                        style={railStyle}
                     />
                 }
                 {this.renderTrack()}
@@ -576,12 +606,12 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                 <div>{this.renderHandle()}</div>
                 {this.renderLabel()}
                 <div className={boundaryClass}>
-                    <span className={`${prefixCls}-boundary-min`}>{this.state.min}</span>
-                    <span className={`${prefixCls}-boundary-max`}>{this.state.max}</span>
+                    <span className={`${prefixCls}-boundary-min`}>{min}</span>
+                    <span className={`${prefixCls}-boundary-max`}>{max}</span>
                 </div>
             </div>
         );
-        if (!this.props.vertical) {
+        if (!vertical) {
             return <div className={sliderCls}>{slider}</div>;
         }
         return slider;

+ 7 - 4
packages/semi-ui/tooltip/index.tsx

@@ -692,7 +692,7 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
         }
 
         // The incoming children is a single valid element, otherwise wrap a layer with span
-        const newChild = React.cloneElement(children as React.ReactElement, {
+        const childNewProps = {
             ...ariaAttribute,
             ...(children as React.ReactElement).props,
             ...this.mergeEvents((children as React.ReactElement).props, triggerEventSet),
@@ -716,9 +716,12 @@ export default class Tooltip extends BaseComponent<TooltipProps, TooltipState> {
                     ref.current = node;
                 }
             },
-            tabIndex: 0, // a11y keyboard
-            'data-popupId': id
-        });
+            'data-popupid': id,
+        };
+        if (trigger === 'hover') {
+            childNewProps['tabIndex'] = 0;
+        }
+        const newChild = React.cloneElement(children as React.ReactElement, childNewProps);
 
         // If you do not add a layer of div, in order to bind the events and className in the tooltip, you need to cloneElement children, but this time it may overwrite the children's original ref reference
         // So if the user adds ref to the content, you need to use callback ref: https://github.com/facebook/react/issues/8873