diff --git a/src/data-table/data-table.styles.tsx b/src/data-table/data-table.styles.tsx index ac91a9d190..c8999d5663 100644 --- a/src/data-table/data-table.styles.tsx +++ b/src/data-table/data-table.styles.tsx @@ -198,6 +198,10 @@ export const HeaderCell = styled.th` `; } }}; + &:focus-within { + outline: 2px solid ${Colour["focus-ring"]}; + outline-offset: -3px; + } `; export const HeaderCellWrapper = styled.div` diff --git a/src/data-table/data-table.tsx b/src/data-table/data-table.tsx index 58aa07e51e..4069474135 100644 --- a/src/data-table/data-table.tsx +++ b/src/data-table/data-table.tsx @@ -5,7 +5,9 @@ import { useResizeDetector } from "react-resize-detector"; import { LoadingDotsSpinner } from "../animations"; import { Checkbox } from "../checkbox"; import { ErrorDisplay } from "../error-display"; +import { VisuallyHidden, concatIds } from "../shared/accessibility"; import { Typography } from "../typography"; +import { SimpleIdGenerator } from "../util"; import { useEventListener } from "../util/use-event-listener"; import { ActionBar, @@ -59,6 +61,13 @@ export const DataTable = ({ const headerRef = useRef(null); const actionBarRef = useRef(null); const wrapperRef = useRef(null); + const [internalId] = useState(() => SimpleIdGenerator.generate()); + const keyColumns = headers.filter( + (header): header is Exclude => { + return typeof header !== "string" && !!header.keyColumn; + } + ); + const [scrollable, setScrollable] = useState(false); const [scrollEnd, setScrollEnd] = useState(false); const [tableEnd, setTableEnd] = useState(false); @@ -150,6 +159,42 @@ export const DataTable = ({ return headers.length + (enableMultiSelect ? 1 : 0); }; + const getSortDirection = (fieldKey: string) => { + return sortIndicators?.[fieldKey]; + }; + + const getHeaderAriaSort = (fieldKey: string) => { + const sortDirection = getSortDirection(fieldKey); + + if (!sortDirection) { + return undefined; + } + + return sortDirection === "asc" ? "ascending" : "descending"; + }; + + const getSortButtonAriaLabel = (fieldKey: string) => { + const nextSortDirection = + getSortDirection(fieldKey) === "asc" ? "descending" : "ascending"; + + return ` by ${nextSortDirection} order`; + }; + + const getHeaderWrapperId = (fieldKey: string) => { + return `${internalId}-header-${fieldKey}`; + }; + const getCellId = (rowId: string, fieldKey: string) => { + return `${internalId}-row-${rowId}-${fieldKey}-key-column`; + }; + + const getRowCheckboxAriaLabelledBy = (rowId: string) => { + const keyColumnIds = keyColumns.map((header) => + getCellId(rowId, header.fieldKey) + ); + + return concatIds(...keyColumnIds); + }; + const calculateFixedInViewport = () => { if (!wrapperRef.current) { return; @@ -228,16 +273,20 @@ export const DataTable = ({ } : header; + const isSortable = !!getSortDirection(fieldKey); + const headerCellWrapperId = getHeaderWrapperId(fieldKey); + return ( clickable && onHeaderClick?.(fieldKey)} + scope="col" + aria-sort={getHeaderAriaSort(fieldKey)} style={style} $isCheckbox={false} > - + {typeof label === "string" ? ( {label} @@ -247,6 +296,15 @@ export const DataTable = ({ )} {renderSortedArrow(fieldKey)} + {(clickable || isSortable) && ( + + + + )} ); }; @@ -260,12 +318,14 @@ export const DataTable = ({ if (isSorted === "asc") { return ( ); } return ( ); @@ -277,12 +337,15 @@ export const DataTable = ({ data-testid={getDataTestId("header-selection")} $clickable={false} $isCheckbox={true} + scope="col" > + Row selection {enableSelectAll && ( { if (onSelectAll) { onSelectAll(isAllCheckboxSelected()); @@ -314,8 +377,7 @@ export const DataTable = ({ $isSelectable={enableMultiSelect} $isSelected={isRowSelected(row.id.toString())} > - {enableMultiSelect && - renderRowCheckBox(row.id.toString())} + {enableMultiSelect && renderRowCheckBox(row)} {headers.map((header) => renderRowCell(header, row))} @@ -335,6 +397,7 @@ export const DataTable = ({ @@ -350,7 +413,9 @@ export const DataTable = ({ ); }; - const renderRowCheckBox = (rowId: string) => { + const renderRowCheckBox = (row: RowProps) => { + const rowId = row.id.toString(); + return ( { if (onSelect) { onSelect(rowId, !isRowSelected(rowId)); } }} disabled={isDisabledRow(rowId)} + focusableWhenDisabled={isDisabledRow(rowId)} /> @@ -404,7 +471,11 @@ export const DataTable = ({ return ( - + {loadState === "loading" && } @@ -431,7 +502,13 @@ export const DataTable = ({ {`${count} item${ count > 1 ? "s" : "" } selected`} - + Clear selection {actionBarContent} diff --git a/src/data-table/types.ts b/src/data-table/types.ts index bdfa03f101..0d47c31fd1 100644 --- a/src/data-table/types.ts +++ b/src/data-table/types.ts @@ -56,6 +56,11 @@ interface HeaderItemProps { // technically ReactNode also includes string, but this is more explicit label: string | ReactNode; clickable?: boolean | undefined; + /** Used with `enableMultiSelect`. Marks this column as descriptive of the row + * (e.g. a name or unique ID). Its value is used to label the row's checkbox + * for screen readers. + */ + keyColumn?: boolean | undefined; style?: CSSProperties | undefined; } diff --git a/stories/data-table/doc-elements.tsx b/stories/data-table/doc-elements.tsx index 4b7c76fc68..0d1f5b130c 100644 --- a/stories/data-table/doc-elements.tsx +++ b/stories/data-table/doc-elements.tsx @@ -37,17 +37,24 @@ export const DataTableWithCustomHeight = ({ children, }: Props) => { const [height, setHeight] = useState("default"); + const rowCountInputId = "data-table-row-count"; + return (
Modify the options to view the scroll behaviour - + Number of rows