Browse Source

feat(a11y): slider a11y (#490)

tank0317 3 years ago
parent
commit
c1d7bede32

+ 15 - 3
content/input/slider/index-en-US.md

@@ -28,19 +28,19 @@ import { Slider } from '@douyinfe/semi-ui';
     <div>
         <div>
             <div>Default</div>
-            <Slider showBoundary={true}></Slider>
+            <Slider aria-label='Slider default' showBoundary={true}></Slider>
         </div>
         <br/>
         <br/>
         <div>
             <div>Range</div>
-            <Slider defaultValue={[20, 60]} range></Slider>
+            <Slider aria-label='Slider range' defaultValue={[20, 60]} range></Slider>
         </div>
         <br/>
         <br/>
         <div>
             <div>Disabled</div>
-            <Slider defaultValue={40} disabled></Slider>
+            <Slider aria-label='Slider disabled' defaultValue={40} disabled></Slider>
         </div>
     </div>
 );
@@ -259,5 +259,17 @@ import { Slider } from '@douyinfe/semi-ui';
 | 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      | -       |- |
 
+## Accessibility
+
+### ARIA
+
+- The element serving as the focusable slider control has `role` 'slider'.
+- The slider element has the `aria-valuenow` property set to a decimal value representing the current value of the slider.
+- 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`.
+- Supporting API `aria-label` `aria-labelledby` to specify Slider label.
+
 ## Design Tokens
 <DesignToken/>

+ 13 - 0
content/input/slider/index.md

@@ -246,5 +246,18 @@ import { Slider } from '@douyinfe/semi-ui';
 | onAfterChange | 与 `onmouseup` 触发时机一致,把当前值作为参数传入 | (value: number \| number[]) => void | 无 |-|
 | onChange | 当 Slider 的值发生改变时的回调 | (value: number \| number[]) => void | 无 |-|
 
+## Accessibility
+
+### ARIA
+
+- Slider 可聚焦的控制元素 role 为 `slider`。
+- 元素的 `aria-valuenow` 属性为当前值的十进制数值。
+- 元素的 `aria-valuemin` 属性为最小允许值的十进制数值。
+- 元素的 `aria-valuemax` 属性为最大允许值的十进制数值。
+- 当 Slider 为纵向时,元素的 `aria-orientation` 属性为 'vertical'。
+- 当 `aria-valuenow` 的值不容易被理解时,支持通过 API `aria-valuetext` 传递一个字符串使其更友好。也可以通过 API `geAriaValueText(value)` 方法得到 `aria-valuetext` 的值。
+- 支持通过 API `aria-label` 或者 `aria-labelledby` 确定 slider 的标签。
+
+
 ## 设计变量
 <DesignToken/>

+ 7 - 0
packages/semi-foundation/slider/foundation.ts

@@ -31,6 +31,10 @@ export interface SliderProps{
     showBoundary?: boolean;
     railStyle?: Record<string, any>;
     verticalReverse?: boolean;
+    'aria-label'?: string;
+    'aria-labelledby'?: string;
+    'aria-valuetext'?: string;
+    getAriaValueText?: (value: number) => string;
 }
 
 export interface SliderState {
@@ -559,6 +563,9 @@ export default class SliderFoundation extends BaseFoundation<SliderAdapter> {
         return true;
     };
 
+    // eslint-disable-next-line @typescript-eslint/no-empty-function
+    onFocus = (e:any, handler: 'min'| 'max') => {}
+
     handleWrapClick = (e: any) => {
         const { disabled, isDrag } = this._adapter.getStates();
         if (isDrag || disabled || this._adapter.isEventFromHandle(e)) {

+ 1 - 1
packages/semi-ui/slider/_story/slider.stories.js

@@ -173,7 +173,7 @@ let divStyle1 = {
 export const VerticalSlider = () => (
   <div>
     <div style={divStyle1}>
-      <Slider vertical range defaultValue={[20, 60]}></Slider>
+      <Slider vertical range defaultValue={[20, 60]} aria-label="slider test"></Slider>
     </div>
   </div>
 );

+ 43 - 7
packages/semi-ui/slider/index.tsx

@@ -78,6 +78,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
     private dragging: boolean[];
     private eventListenerSet: Set<() => void>;
     private chooseMovePos: 'min' | 'max';
+    foundation: SliderFoundation;
 
     constructor(props: SliderProps) {
         super(props);
@@ -281,7 +282,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
     }
 
     renderHandle = () => {
-        const { vertical, range, tooltipVisible, tipFormatter } = this.props;
+        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 stylePos = vertical ? 'top' : 'left';
         const percentInfo = this.foundation.getMinAndMaxPercent(this.state.currentValue);
@@ -299,6 +300,15 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
         const maxClass = cls(cssClasses.HANDLE, {
             [`${cssClasses.HANDLE}-clicked`]: chooseMovePos === 'max' && isDrag,
         });
+        const {min, max, currentValue} = this.state;
+
+        const commonAria = {
+            'aria-label': ariaLabel,
+            'aria-labelledby': ariaLabelledby,
+            'aria-disabled': disabled
+        };
+        vertical && Object.assign(commonAria, {'aria-orientation': 'vertical'});
+
         const handleContents = !range ? (
             <Tooltip
                 content={tipChildren.min}
@@ -338,6 +348,14 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                     onTouchEnd={e => {
                         this.foundation.onHandleUp(e);
                     }}
+                    onFocus={e => this.foundation.onFocus(e, 'min')}
+                    role="slider"
+                    tabIndex={0}
+                    {...commonAria}
+                    aria-valuenow={currentValue as number}
+                    aria-valuemax={max}
+                    aria-valuemin={min}
+                    aria-valuetext={getAriaValueText ? getAriaValueText(currentValue as number) : ariaValueText}
                 />
             </Tooltip>
         ) : (
@@ -379,6 +397,14 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                         onTouchEnd={e => {
                             this.foundation.onHandleUp(e);
                         }}
+                        onFocus={e => this.foundation.onFocus(e, 'min')}
+                        role="slider"
+                        tabIndex={0}
+                        {...commonAria}
+                        aria-valuenow={currentValue[0]}
+                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[0]) : ariaValueText}
+                        aria-valuemax={currentValue[1]}
+                        aria-valuemin={min}
                     />
                 </Tooltip>
                 <Tooltip
@@ -418,6 +444,14 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                         onTouchEnd={e => {
                             this.foundation.onHandleUp(e);
                         }}
+                        onFocus={e => this.foundation.onFocus(e, 'min')}
+                        role="slider"
+                        tabIndex={0}
+                        {...commonAria}
+                        aria-valuenow={currentValue[1]}
+                        aria-valuetext={getAriaValueText ? getAriaValueText(currentValue[1]) : ariaValueText}
+                        aria-valuemax={max}
+                        aria-valuemin={currentValue[0]}
                     />
                 </Tooltip>
             </React.Fragment>
@@ -440,7 +474,7 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                 top: range ? `${minPercent * 100}%` : 0,
             };
         trackStyle = included ? trackStyle : {};
-        return (
+        return (// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
             <div className={cssClasses.TRACK} style={trackStyle} onClick={e => this.foundation.handleWrapClick(e)}>
                 {/* {this.renderTrack} */}
             </div>
@@ -522,11 +556,13 @@ export default class Slider extends BaseComponent<SliderProps, SliderState> {
                 onMouseEnter={() => this.foundation.handleWrapperEnter()}
                 onMouseLeave={() => this.foundation.handleWrapperLeave()}
             >
-                <div
-                    className={`${prefixCls}-rail`}
-                    onClick={e => this.foundation.handleWrapClick(e)}
-                    style={this.props.railStyle}
-                />
+                {// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+                    <div
+                        className={`${prefixCls}-rail`}
+                        onClick={e => this.foundation.handleWrapClick(e)}
+                        style={this.props.railStyle}
+                    />
+                }
                 {this.renderTrack()}
                 {this.renderStepDot()}
                 <div>{this.renderHandle()}</div>