Explorar o código

feat(a11y): table aria #205

走鹃 %!s(int64=3) %!d(string=hai) anos
pai
achega
a15ea0c1cb

+ 17 - 0
packages/semi-foundation/table/utils.ts

@@ -470,4 +470,21 @@ export interface GetAllDisabledRowKeysProps {
     getCheckboxProps: (record?: Record<string, any>) => any;
     childrenRecordName?: string;
     rowKey?: string | number | ((record: Record<string, any>) => string | number);
+}
+
+/**
+ * Whether is tree table
+ */
+export function isTreeTable({ dataSource, childrenRecordName = 'children' }: { dataSource: Record<string, any>; childrenRecordName?: string; }) {
+    let flag = false;
+    if (Array.isArray(dataSource)) {
+        for (const data of dataSource) {
+            const children = get(data, childrenRecordName);
+            if (Array.isArray(children) && children.length) {
+                flag = true;
+                break;
+            }
+        }
+    }
+    return flag;
 }

+ 5 - 1
packages/semi-ui/checkbox/checkbox.tsx

@@ -19,6 +19,7 @@ export interface CheckboxProps extends BaseCheckboxProps {
     onMouseEnter?: React.MouseEventHandler<HTMLSpanElement>;
     onMouseLeave?: React.MouseEventHandler<HTMLSpanElement>;
     extra?: React.ReactNode;
+    ariaLabel?: string;
 }
 interface CheckboxState {
     checked: boolean;
@@ -47,6 +48,7 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
         addonId: PropTypes.string, // A11y aria-labelledby
         extraId: PropTypes.string, // A11y aria-describedby
         index: PropTypes.number,
+        ariaLabel: PropTypes.string,
     };
 
     static defaultProps = {
@@ -130,7 +132,8 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             extra,
             value,
             addonId,
-            extraId
+            extraId,
+            ariaLabel
         } = this.props;
         const { checked } = this.state;
         const props: Record<string, any> = {
@@ -182,6 +185,7 @@ class Checkbox extends BaseComponent<CheckboxProps, CheckboxState> {
             <span
                 role='checkbox'
                 tabIndex={disabled ? -1 : 0}
+                aria-label={ariaLabel}
                 aria-disabled={props.checked}
                 aria-checked={props.checked}
                 aria-labelledby={addonId}

+ 25 - 1
packages/semi-ui/table/Body/BaseRow.tsx

@@ -238,7 +238,7 @@ export default class TableRow extends BaseComponent<BaseRowProps, Record<string,
             }
 
             if (isExpandedColumn(column) && !displayExpandedColumn) {
-                cells.push(<TableCell key={columnIndex} isSection={isSection} />);
+                cells.push(<TableCell key={columnIndex} colIndex={columnIndex} isSection={isSection} />);
             } else if (!isScrollbarColumn(column)) {
                 const diyProps: { width?: number } = {};
 
@@ -248,6 +248,7 @@ export default class TableRow extends BaseComponent<BaseRowProps, Record<string,
 
                 cells.push(
                     <TableCell
+                        colIndex={columnIndex}
                         {...expandableProps}
                         {...diyProps}
                         hideExpandedColumn={hideExpandedColumn}
@@ -318,6 +319,10 @@ export default class TableRow extends BaseComponent<BaseRowProps, Record<string,
             record,
             hovered,
             expanded,
+            expandableRow,
+            level,
+            expandedRow,
+            isSection
         } = this.props;
 
         const BodyRow: any = components.body.row;
@@ -341,9 +346,28 @@ export default class TableRow extends BaseComponent<BaseRowProps, Record<string,
                     },
                     customClassName
                 );
+        const ariaProps = {};
+        if (typeof index === 'number') {
+            ariaProps['aria-rowindex'] = index + 1;
+        }
+        if (expandableRow) {
+            ariaProps['aria-expanded'] = expanded;
+        }
+        // if row is expandedRow, set it's level to 2 
+        if (expanded || expandedRow) {
+            ariaProps['aria-level'] = 2;
+        }
+        if (typeof level === 'number') {
+            ariaProps['aria-level'] = level + 1;
+        }
+        if (isSection) {
+            ariaProps['aria-level'] = 1;
+        }
 
         return (
             <BodyRow
+                role="row"
+                {...ariaProps}
                 {...rowProps}
                 style={baseRowStyle}
                 className={rowCls}

+ 7 - 2
packages/semi-ui/table/Body/index.tsx

@@ -3,7 +3,7 @@
 /* eslint-disable max-len */
 import React, { ReactNode } from 'react';
 import PropTypes from 'prop-types';
-import { get, size, isMap, each, isEqual, pick, isNull } from 'lodash-es';
+import { get, size, isMap, each, isEqual, pick, isNull, isFunction } from 'lodash-es';
 import classnames from 'classnames';
 import { VariableSizeList as List } from 'react-window';
 
@@ -15,7 +15,8 @@ import {
     isDisabled,
     getRecord,
     genExpandedRowKey,
-    getDefaultVirtualizedRowConfig
+    getDefaultVirtualizedRowConfig,
+    isTreeTable
 } from '@douyinfe/semi-foundation/table/utils';
 import BodyFoundation, { BodyAdapter, FlattenData, GroupFlattenData } from '@douyinfe/semi-foundation/table/bodyFoundation';
 import { strings } from '@douyinfe/semi-foundation/table/constants';
@@ -730,6 +731,7 @@ class Body extends BaseComponent<BodyProps, BodyState> {
             dataSource,
             onScroll,
             groups,
+            expandedRowRender,
         } = this.props;
 
         const x = get(scroll, 'x');
@@ -775,6 +777,9 @@ class Body extends BaseComponent<BodyProps, BodyState> {
                 onScroll={handleBodyScroll}
             >
                 <Table
+                    role={ isMap(groups) || isFunction(expandedRowRender) || isTreeTable({ dataSource }) ? 'treegrid' : 'grid'}
+                    aria-rowcount={dataSource && dataSource.length}
+                    aria-colcount={columns && columns.length}
                     style={tableStyle}
                     className={classnames(prefixCls, {
                         [`${prefixCls}-fixed`]: anyColumnFixed,

+ 7 - 1
packages/semi-ui/table/ColumnFilter.tsx

@@ -164,7 +164,13 @@ export default function ColumnFilter(props: ColumnFilterProps = {}): React.React
     } else {
         iconElem = (
             <div className={finalCls}>
-                <IconFilter size="small" />
+                <IconFilter
+                    role="button"
+                    aria-label="Filter data with this column"
+                    aria-haspopup="listbox"
+                    tabIndex={-1}
+                    size="small"
+                />
             </div>
         );
     }

+ 4 - 2
packages/semi-ui/table/ColumnSelection.tsx

@@ -20,6 +20,7 @@ export interface TableSelectionCellProps {
     indeterminate?: boolean; // Intermediate state, shown as a solid horizontal line
     prefixCls?: string;
     className?: string;
+    ariaLabel?: string;
 }
 
 /**
@@ -36,6 +37,7 @@ export default class TableSelectionCell extends BaseComponent<TableSelectionCell
         indeterminate: PropTypes.bool,
         prefixCls: PropTypes.string,
         className: PropTypes.string,
+        ariaLabel: PropTypes.string,
     };
 
     static defaultProps = {
@@ -59,7 +61,7 @@ export default class TableSelectionCell extends BaseComponent<TableSelectionCell
     handleChange = (e: CheckboxEvent) => this.foundation.handleChange(e);
 
     render() {
-        const { selected, getCheckboxProps, indeterminate, disabled, prefixCls, className } = this.props;
+        const { selected, getCheckboxProps, indeterminate, disabled, prefixCls, className, ariaLabel } = this.props;
         let checkboxProps = {
             onChange: this.handleChange,
             disabled,
@@ -81,7 +83,7 @@ export default class TableSelectionCell extends BaseComponent<TableSelectionCell
 
         return (
             <span className={wrapCls}>
-                <Checkbox {...checkboxProps} />
+                <Checkbox ariaLabel={ariaLabel} {...checkboxProps} />
             </span>
         );
     }

+ 18 - 1
packages/semi-ui/table/ColumnSorter.tsx

@@ -7,6 +7,7 @@ import { IconCaretup, IconCaretdown } from '@douyinfe/semi-icons';
 import { cssClasses, strings } from '@douyinfe/semi-foundation/table/constants';
 
 import { SortOrder } from './interface';
+import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
 
 export interface ColumnSorterProps {
     className?: string;
@@ -43,9 +44,25 @@ export default class ColumnSorter extends PureComponent<ColumnSorterProps> {
         const downCls = cls(`${prefixCls}-column-sorter-down`, {
             on: sortOrder === strings.SORT_DIRECTIONS[1],
         });
+        const ariaProps = {
+            /**
+             * Set 'aria-sort' to aria-columnheader is difficult, so set 'aria-label' about sort info to sorter
+             * reference: https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaSort
+             */
+            'aria-label': `Current sort order is ${sortOrder ? `${sortOrder}ing` : 'none'}`,
+            'aria-roledescription': 'Sort data with this column',
+        };
 
         return (
-            <div style={style} className={`${prefixCls}-column-sorter`} onClick={onClick}>
+            <div
+                role='button'
+                {...ariaProps}
+                tabIndex={-1}
+                style={style}
+                className={`${prefixCls}-column-sorter`}
+                onClick={onClick}
+                onKeyPress={e => isEnterPress(e) && onClick(e as any)}
+            >
                 <span className={`${upCls}`}>
                     <IconCaretup size={iconBtnSize} />
                 </span>

+ 5 - 0
packages/semi-ui/table/CustomExpandIcon.tsx

@@ -5,6 +5,7 @@ import { noop } from 'lodash-es';
 
 import { IconChevronRight, IconChevronDown, IconTreeTriangleDown, IconTreeTriangleRight } from '@douyinfe/semi-icons';
 import { cssClasses } from '@douyinfe/semi-foundation/table/constants';
+import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
 
 import Rotate from '../motions/Rotate';
 
@@ -65,10 +66,14 @@ export default function CustomExpandIcon(props: CustomExpandIconProps) {
 
     return (
         <span
+            role="button"
+            aria-label="Expand this row"
+            tabIndex={-1}
             onClick={handleClick}
             onMouseEnter={onMouseEnter}
             onMouseLeave={onMouseLeave}
             className={`${prefixCls}-expand-icon`}
+            onKeyPress={e => isEnterPress(e) && handleClick(e as any)}
         >
             {icon}
         </span>

+ 2 - 0
packages/semi-ui/table/Table.tsx

@@ -790,6 +790,7 @@ class Table<RecordType extends Record<string, any>> extends BaseComponent<Normal
                 const hasRowSelected = this.foundation.hasRowSelected(selectedRowKeys, allRowKeysSet);
                 return (
                     <ColumnSelection
+                        ariaLabel={`${allIsSelected ? 'Deselect' : 'Select'} all rows`}
                         disabled={disabled}
                         key={columnKey}
                         selected={allIsSelected}
@@ -806,6 +807,7 @@ class Table<RecordType extends Record<string, any>> extends BaseComponent<Normal
 
                 return (
                     <ColumnSelection
+                        ariaLabel={`${selected ? 'Select' : 'Deselect'} this row`}
                         getCheckboxProps={checkboxPropsFn}
                         selected={selected}
                         onChange={(status, e) => this.toggleSelectRow(status, key, e)}

+ 11 - 1
packages/semi-ui/table/TableCell.tsx

@@ -42,6 +42,7 @@ export interface TableCellProps extends BaseProps {
     selected?: boolean; // Whether the current row is selected
     expanded?: boolean; // Whether the current line is expanded
     disabled?: boolean;
+    colIndex?: number;
 }
 
 function isInvalidRenderCellText(text: any) {
@@ -82,6 +83,7 @@ export default class TableCell extends BaseComponent<TableCellProps, Record<stri
         height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
         selected: PropTypes.bool,
         expanded: PropTypes.bool,
+        colIndex: PropTypes.number,
     };
 
     get adapter(): TableCellAdapter {
@@ -314,6 +316,7 @@ export default class TableCell extends BaseComponent<TableCellProps, Record<stri
             fixedRight,
             lastFixedLeft,
             firstFixedRight,
+            colIndex
         } = this.props;
         const { className } = column;
         const fixedLeftFlag = fixedLeft || typeof fixedLeft === 'number';
@@ -347,7 +350,14 @@ export default class TableCell extends BaseComponent<TableCellProps, Record<stri
         );
 
         return (
-            <BodyCell className={columnCls} onClick={this.handleClick} {...newTdProps} ref={this.setRef}>
+            <BodyCell
+                role="gridcell"
+                aria-colindex={colIndex + 1}
+                className={columnCls}
+                onClick={this.handleClick}
+                {...newTdProps}
+                ref={this.setRef}
+            >
                 {inner}
             </BodyCell>
         );

+ 16 - 2
packages/semi-ui/table/TableHeaderRow.tsx

@@ -168,11 +168,25 @@ export default class TableHeaderRow extends BaseComponent<TableHeaderRowProps, R
                 return null;
             }
 
-            return <HeaderCell {...props} style={cellStyle} key={column.key || column.dataIndex || cellIndex} />;
+            return (
+                <HeaderCell
+                    role="columnheader"
+                    aria-colindex={cellIndex + 1}
+                    {...props}
+                    style={cellStyle} 
+                    key={column.key || column.dataIndex || cellIndex} 
+                />
+            );
         });
 
         return (
-            <HeaderRow {...rowProps} style={style} ref={this.cacheRef}>
+            <HeaderRow
+                role="row"
+                aria-rowindex={index + 1}
+                {...rowProps}
+                style={style}
+                ref={this.cacheRef}
+            >
                 {cells}
             </HeaderRow>
         );