Browse Source

feat: [Tree/TreeSelect] support A11y aria (#493)

boomboomchen 3 years ago
parent
commit
7ca9035614

+ 7 - 0
content/input/treeselect/index-en-US.md

@@ -1259,5 +1259,12 @@ function Demo() {
 - search(sugInput: string)
 For custom rendering of input box.
 
+## Accessibility
+
+### Aria
+
+- TreeSelect supports passing in  `aria-label`、`aria-describedby`、`aria-errormessage`、`aria-invalid`、`aria-labelledby`、`aria-required` to indicate the role of the TreeSelect;
+- TreeSelect will set `aria-disabled`, `aria-checked`, `aria-selected`, and `aria-level` for each child node to indicate the node status and level.
+
 ## Design Tokens
 <DesignToken/>

+ 9 - 0
content/input/treeselect/index.md

@@ -1235,6 +1235,15 @@ function Demo() {
 | key | required且要求唯一 | string | - |
 | isLeaf| 是否为叶子节点 | boolean |-|
 
+
+## Accessibility
+
+### Aria
+
+- TreeSelect 支持传入 `aria-label`、`aria-describedby`、`aria-errormessage`、`aria-invalid`、`aria-labelledby`、`aria-required` 来表示该 TreeSelect 作用;
+- TreeSelect 会为每个子节点分别设置 `aria-disabled`、`aria-checked`、`aria-selected`、`aria-level` 来表明节点状态及层级;
+
+
 ## 设计变量
 <DesignToken/>
 

+ 15 - 5
content/navigation/tree/index-en-US.md

@@ -1305,13 +1305,13 @@ import { Tree, Checkbox } from '@douyinfe/semi-ui';
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={isLeaf ? onCheck : onExpand}
                 onContextMenu={onContextMenu}
                 onDoubleClick={onDoubleClick}
             >
                 {isLeaf ? null : expandIcon}
-                {isLeaf ? <div onClick={onCheck}>
+                {isLeaf ? <div onClick={onCheck} role='checkbox' tabIndex={0} aria-checked={checkStatus.checked}>
                     <Checkbox
                         indeterminate={checkStatus.halfChecked}
                         checked={checkStatus.checked}
@@ -1416,7 +1416,7 @@ import { Tree } from '@douyinfe/semi-ui';
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={isLeaf ? onClick : onExpand}
             >
                 {isLeaf ? null : expandIcon}
@@ -1596,7 +1596,7 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={onClick}
                 style={style}
             >
@@ -1775,7 +1775,7 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={onClick}
                 style={style}
             >
@@ -1894,5 +1894,15 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
 ### Ref Method
 - search(sugInput) => void
 
+
+## Accessibility
+
+### Aria
+
+- Tree supports passing in `aria-label` to indicate the role of the Tree;
+- Tree will set `aria-disabled`, `aria-checked`, `aria-selected`, and `aria-level` for each child node to indicate the node status and level;
+- Tree will set `role` to `tree` and `treeitem` for corresponding parts;
+- Tree supports multiple selections by pressing Enter to select nodes.
+
 ## Design Tokens
 <DesignToken/>

+ 14 - 5
content/navigation/tree/index.md

@@ -1330,11 +1330,11 @@ import { Tree, Checkbox } from '@douyinfe/semi-ui';
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={isLeaf ? onCheck : onExpand}
             >
                 {isLeaf ? null : expandIcon}
-                {isLeaf ? <div onClick={onCheck}>
+                {isLeaf ? <div onClick={onCheck} role='checkbox' tabIndex={0} aria-checked={checkStatus.checked}>
                     <Checkbox
                         indeterminate={checkStatus.halfChecked}
                         checked={checkStatus.checked}
@@ -1439,7 +1439,7 @@ import { Tree } from '@douyinfe/semi-ui';
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={isLeaf ? onClick : onExpand}
             >
                 {isLeaf ? null : expandIcon}
@@ -1615,7 +1615,7 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={onClick}
                 style={style}
             >
@@ -1793,7 +1793,7 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
         return (
             <li
                 className={className}
-                role="treenode"
+                role="treeitem"
                 onClick={onClick}
                 style={style}
             >
@@ -1909,6 +1909,15 @@ import { IconFixedStroked, IconSectionStroked, IconAbsoluteStroked, IconInnerSec
 | itemSize | 每行的treeNode的高度,必传 | number | - |
 | width | 宽度值 | number\|string | '100%' |
 
+## Accessibility
+
+### Aria
+
+- Tree 支持传入 `aria-label` 来表示该 Tree 作用;
+- Tree 会为每个子节点分别设置 `aria-disabled`、`aria-checked`、`aria-selected`、`aria-level` 来表明节点状态及层级;
+- Tree 会为对应部分分别设置 `role` 为 `tree`、`treeitem`;
+- Tree 支持多选时通过按下 Enter 键来选中节点。
+
 ## 设计变量
 <DesignToken/>
 

+ 1 - 0
packages/semi-foundation/tree/foundation.ts

@@ -233,6 +233,7 @@ export interface BasicTreeProps {
     value?: BasicValue;
     virtualize?: Virtualize;
     icon?: any;
+    'aria-label'?: string;
 }
 
 /* Data maintained internally. At the React framework level, corresponding to state */

+ 20 - 0
packages/semi-foundation/treeSelect/foundation.ts

@@ -23,6 +23,7 @@ import {
     BasicExpandedOtherProps
 } from '../tree/foundation';
 import { Motion } from '../utils/type';
+import isEnterPress from '../utils/isEnterPress';
 
 /* Here ValidateStatus is the same as ValidateStatus in baseComponent */
 export type ValidateStatus = 'error' | 'warning' | 'default';
@@ -93,6 +94,7 @@ export interface BasicTreeSelectProps extends Pick<BasicTreeProps,
 | 'onSearch'
 | 'expandAll'
 | 'disableStrictly'
+| 'aria-label'
 > {
     motion?: Motion;
     mouseEnterDelay?: number;
@@ -417,6 +419,15 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
         }
     }
 
+    /**
+     * A11y: simulate selection click
+     */
+    handleSelectionEnterPress(e: any) {
+        if (isEnterPress(e)) {
+            this.handleClick(e);
+        }
+    }
+
     handleClear(e: any) {
         const { searchPosition, filterTreeNode } = this.getProps();
         const { inputValue, selectedKeys } = this.getStates();
@@ -445,6 +456,15 @@ export default class TreeSelectFoundation<P = Record<string, any>, S = Record<st
         }
     }
 
+    /**
+     * A11y: simulate clear button click
+     */
+    handleClearEnterPress(e: any) {
+        if (isEnterPress(e)) {
+            this.handleClear(e);
+        }
+    }
+
     removeTag(eventKey: BasicTreeNodeData['key']) {
         const { disableStrictly } = this.getProps();
         const { keyEntities, disabledKeys } = this.getStates();

+ 3 - 3
packages/semi-ui/tree/_story/tree.stories.js

@@ -1279,7 +1279,7 @@ const RefSearch = () => {
   ];
   return (
     <div>
-      <Input onChange={v => ref.current.search(v)} />
+      <Input aria-label='filter tree' onChange={v => ref.current.search(v)} />
       <Tree
         treeData={treeData}
         defaultValue="Shanghai"
@@ -1814,7 +1814,7 @@ const MutipleHLTree = () => {
         : 'transparent',
     };
     return (
-      <li className={className} role="treenode" onClick={onClick} style={style}>
+      <li className={className} role="treeitem" onClick={onClick} style={style}>
         {isLeaf ? null : expandIcon}
         {icon ? icon : null}
         <span>{label}</span>
@@ -2141,7 +2141,7 @@ export const RenderFullLabelWithDraggable = () => {
         : 'transparent',
     };
     return (
-      <li className={className} role="treenode" onClick={onClick} style={style}>
+      <li className={className} role="treeitem" onClick={onClick} style={style}>
         {isLeaf ? <span style={{ width: 24 }}></span> : expandIcon}
         {icon ? icon : null}
         <span>{label}</span>

+ 10 - 2
packages/semi-ui/tree/index.tsx

@@ -112,6 +112,7 @@ class Tree extends BaseComponent<TreeProps, TreeState> {
         onDragStart: PropTypes.func,
         onDrop: PropTypes.func,
         labelEllipsis: PropTypes.bool,
+        'aria-label': PropTypes.string,
     };
 
     static defaultProps = {
@@ -511,6 +512,7 @@ class Tree extends BaseComponent<TreeProps, TreeState> {
                         }
                         return (
                             <Input
+                                aria-label='Filter Tree'
                                 ref={this.inputRef as any}
                                 {...inputProps}
                             />
@@ -701,6 +703,12 @@ class Tree extends BaseComponent<TreeProps, TreeState> {
         });
         const searchNoRes = Boolean(inputValue) && !filteredKeys.size;
         const noData = isEmpty(keyEntities) || (showFilteredOnly && searchNoRes);
+        const ariaAttr = {
+            role: noData ? 'none' : 'tree'
+        };
+        if (ariaAttr.role === 'tree'){
+            ariaAttr['aria-multiselectable'] = multiple ? true : false;
+        }
         return (
             <TreeContext.Provider
                 value={{
@@ -739,9 +747,9 @@ class Tree extends BaseComponent<TreeProps, TreeState> {
                     labelEllipsis: typeof labelEllipsis === 'undefined' ? virtualize : labelEllipsis,
                 }}
             >
-                <div className={wrapperCls} role="listbox" style={style}>
+                <div aria-label={this.props['aria-label']} className={wrapperCls} style={style}>
                     {filterTreeNode ? this.renderInput() : null}
-                    <div className={listCls} role="tree">
+                    <div className={listCls} {...ariaAttr}>
                         {noData ? this.renderEmpty() : this.renderNodeList()}
                     </div>
                 </div>

+ 46 - 10
packages/semi-ui/tree/treeNode.tsx

@@ -2,7 +2,8 @@ import React, { PureComponent } from 'react';
 import cls from 'classnames';
 import PropTypes from 'prop-types';
 import { cssClasses } from '@douyinfe/semi-foundation/tree/constants';
-import { debounce, isFunction, isString } from 'lodash';
+import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
+import { debounce, isFunction, isString, get } from 'lodash';
 import { IconTreeTriangleDown, IconFile, IconFolder, IconFolderOpen } from '@douyinfe/semi-icons';
 import { Checkbox } from '../checkbox';
 import TreeContext from './treeContext';
@@ -51,19 +52,19 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
         });
     }
 
-    onSelect = (e: React.MouseEvent) => {
+    onSelect = (e: React.MouseEvent | React.KeyboardEvent) => {
         const { onNodeSelect } = this.context;
         onNodeSelect(e, this.props);
     };
 
-    onExpand = (e: React.MouseEvent) => {
+    onExpand = (e: React.MouseEvent | React.KeyboardEvent) => {
         const { onNodeExpand } = this.context;
         e && e.stopPropagation();
         e.nativeEvent.stopImmediatePropagation();
         onNodeExpand(e, this.props);
     };
 
-    onCheck = (e: React.MouseEvent) => {
+    onCheck = (e: React.MouseEvent | React.KeyboardEvent) => {
         if (this.isDisabled()) {
             return;
         }
@@ -73,12 +74,21 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
         onNodeCheck(e, this.props);
     };
 
+    /**
+     * A11y: simulate checkbox click
+     */
+    handleCheckEnterPress = (e: React.KeyboardEvent) => {
+        if (isEnterPress(e)) {
+            this.onCheck(e);
+        }
+    }
+
     onContextMenu = (e: React.MouseEvent) => {
         const { onNodeRightClick } = this.context;
         onNodeRightClick(e, this.props);
     };
 
-    onClick = (e: React.MouseEvent) => {
+    onClick = (e: React.MouseEvent | React.KeyboardEvent) => {
         const { expandAction } = this.context;
         if (expandAction === 'doubleClick') {
             this.debounceSelect(e);
@@ -90,6 +100,15 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
         }
     };
 
+    /**
+     * A11y: simulate li click
+     */
+    handleliEnterPress = (e: React.KeyboardEvent) => {
+        if (isEnterPress(e)) {
+            this.onClick(e);
+        }
+    }
+
     onDoubleClick = (e: React.MouseEvent) => {
         const { expandAction, onNodeDoubleClick } = this.context;
         e.stopPropagation();
@@ -179,13 +198,15 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
 
     renderArrow() {
         const showIcon = !this.isLeaf();
-        const { loading } = this.props;
+        const { loading, expanded } = this.props;
         if (loading) {
             return <Spin wrapperClassName={`${prefixcls}-spin-icon`} />;
         }
         if (showIcon) {
             return (
                 <IconTreeTriangleDown
+                    role='button'
+                    aria-label={`${expanded ? 'Expand' : 'Collapse'} the tree item`} 
                     className={`${prefixcls}-expand-icon`}
                     size="small"
                     onClick={this.onExpand}
@@ -201,8 +222,13 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
         const { checked, halfChecked } = this.props;
         const disabled = this.isDisabled();
         return (
-            <div onClick={this.onCheck}>
+            <div
+                role='none'
+                onClick={this.onCheck} 
+                onKeyPress={this.handleCheckEnterPress}
+            >
                 <Checkbox
+                    aria-label='Toggle the checked state of checkbox'
                     indeterminate={halfChecked}
                     checked={checked}
                     disabled={Boolean(disabled)}
@@ -242,9 +268,9 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
         });
         return (
             <ul className={wrapperCls}>
-                <span className={`${prefixcls}-label ${prefixcls}-label-empty`}>
+                <li className={`${prefixcls}-label ${prefixcls}-label-empty`}>
                     {emptyContent}
-                </span>
+                </li>
             </ul>
         );
     }
@@ -373,12 +399,22 @@ export default class TreeNode extends PureComponent<TreeNodeProps, TreeNodeState
             [`${prefixcls}-drag-over-gap-top`]: !disabled && dragOverGapTop,
             [`${prefixcls}-drag-over-gap-bottom`]: !disabled && dragOverGapBottom,
         });
+        const setsize = get(rest, ['data', 'children', 'length']);
+        const posinset = isString(rest.pos) ? Number(rest.pos.split('-')[level+1]) + 1 : 1;
         return (
             <li
                 className={nodeCls}
-                role="treenode"
+                role="treeitem"
+                aria-disabled={disabled} 
+                aria-checked={checked}
+                aria-selected={selected}
+                aria-setsize={setsize}
+                aria-posinset={posinset}
+                aria-expanded={expanded}
+                aria-level={level+1}
                 data-key={eventKey}
                 onClick={this.onClick}
+                onKeyPress={this.handleliEnterPress}
                 onContextMenu={this.onContextMenu}
                 onDoubleClick={this.onDoubleClick}
                 ref={this.setRef}

+ 33 - 7
packages/semi-ui/treeSelect/index.tsx

@@ -250,6 +250,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         optionListStyle: PropTypes.object,
         searchRender: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
         renderSelectedItem: PropTypes.func,
+        'aria-label': PropTypes.string,
     };
 
     static defaultProps: Partial<TreeSelectProps> = {
@@ -278,6 +279,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         expandAction: false,
         clickToHide: true,
         searchAutoFocus: false,
+        'aria-label': 'TreeSelect'
     };
     inputRef: React.RefObject<typeof Input>;
     tagInputRef: React.RefObject<TagInput>;
@@ -288,6 +290,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
     onNodeClick: any;
     onNodeDoubleClick: any;
     onMotionEnd: any;
+    treeSelectID: string;
 
     constructor(props: TreeSelectProps) {
         super(props);
@@ -323,6 +326,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         this.optionsRef = React.createRef();
         this.clickOutsideHandler = null;
         this.foundation = new TreeSelectFoundation(this.adapter);
+        this.treeSelectID = Math.random().toString(36).slice(2);
     }
 
     // eslint-disable-next-line max-lines-per-function
@@ -645,7 +649,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         const style = { minWidth: dropdownMinWidth, ...dropdownStyle };
         const popoverCls = cls(dropdownClassName, `${prefixcls}-popover`);
         return (
-            <div className={popoverCls} role="listbox" style={style}>
+            <div className={popoverCls} style={style}>
                 {this.renderTree()}
             </div>
         );
@@ -659,6 +663,10 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         this.foundation.handleClick(e);
     };
 
+    handleSelectionEnterPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
+        this.foundation.handleSelectionEnterPress(e);
+    };
+
     showClearBtn = () => {
         const { searchPosition } = this.props;
         const { inputValue } = this.state;
@@ -794,6 +802,11 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         this.foundation.handleClear(e);
     };
 
+    handleClearEnterPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
+        e && e.stopPropagation();
+        this.foundation.handleClearEnterPress(e);
+    };
+
     handleMouseOver = (e: React.MouseEvent) => {
         this.foundation.toggleHoverState(true);
     };
@@ -824,7 +837,14 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
         const clearCls = cls(`${prefixcls}-clearbtn`);
         if (showClearBtn) {
             return (
-                <div className={clearCls} onClick={this.handleClear} role='button' tabIndex={0}>
+                <div 
+                    role='button'
+                    tabIndex={0} 
+                    aria-label="Clear TreeSelect value" 
+                    className={clearCls} 
+                    onClick={this.handleClear}
+                    onKeyPress={this.handleClearEnterPress}
+                >
                     <IconClear />
                 </div>
             );
@@ -914,25 +934,30 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
                 <Fragment key={'arrow'}>{this.renderArrow()}</Fragment>,
             ]
         );
+        const tabIndex = disabled ? null : 0;
         /**
          * Reasons for disabling the a11y eslint rule:
          * The following attributes(aria-controls,aria-expanded) will be automatically added by Tooltip, no need to declare here
          */
         return (
             <div
+                // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
+                role='combobox'
+                aria-disabled={disabled}
+                aria-haspopup="tree"
+                tabIndex={tabIndex}
                 className={classNames}
                 style={style}
                 ref={this.triggerRef}
                 onClick={this.handleClick}
+                onKeyPress={this.handleSelectionEnterPress}
                 aria-invalid={this.props['aria-invalid']}
                 aria-errormessage={this.props['aria-errormessage']}
+                aria-label={this.props['aria-label']} 
                 aria-labelledby={this.props['aria-labelledby']}
                 aria-describedby={this.props['aria-describedby']}
                 aria-required={this.props['aria-required']}
                 {...mouseEvent}
-                // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
-                role='combobox'
-                tabIndex={0}
             >
                 {inner}
             </div>
@@ -1077,6 +1102,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
                         }
                         return (
                             <Input
+                                aria-label='Filter TreeSelect item'
                                 ref={this.inputRef as any}
                                 autofocus={searchAutoFocus}
                                 placeholder={placeholder}
@@ -1255,7 +1281,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
                     labelEllipsis: typeof labelEllipsis === 'undefined' ? virtualize : labelEllipsis,
                 }}
             >
-                <div className={wrapperCls} role="listbox">
+                <div className={wrapperCls}>
                     {outerTopSlot}
                     {
                         !outerTopSlot &&
@@ -1263,7 +1289,7 @@ class TreeSelect extends BaseComponent<TreeSelectProps, TreeSelectState> {
                         isDropdownPositionSearch &&
                         this.renderInput()
                     }
-                    <div className={listCls} role="tree" style={optionListStyle}>
+                    <div className={listCls} role="tree" aria-multiselectable={multiple ? true : false} style={optionListStyle}>
                         {noData ? this.renderEmpty() : this.renderNodeList()}
                     </div>
                     {outerBottomSlot}