Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/data-table/data-table.styles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export const HeaderRow = styled.tr`
`;

export const HeaderCell = styled.th<HeaderCellProps>`
position: relative;
padding: ${(props) =>
props.$isCheckbox ? "1.25rem 0.5rem 1.25rem 1.5rem" : "1.25rem 1rem"};
text-align: left;
Expand All @@ -198,6 +199,10 @@ export const HeaderCell = styled.th<HeaderCellProps>`
`;
}
}};
&:focus-within {
outline: 2px solid ${Colour["focus-ring"]};
outline-offset: -3px;
}
`;

export const HeaderCellWrapper = styled.div`
Expand Down
89 changes: 82 additions & 7 deletions src/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,6 +61,13 @@ export const DataTable = ({
const headerRef = useRef<HTMLTableSectionElement>(null);
const actionBarRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [internalId] = useState(() => SimpleIdGenerator.generate());
const keyColumns = headers.filter(
(header): header is Exclude<HeaderProps, string> => {
return typeof header !== "string" && !!header.keyColumn;
}
);

const [scrollable, setScrollable] = useState(false);
const [scrollEnd, setScrollEnd] = useState(false);
const [tableEnd, setTableEnd] = useState(false);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -228,16 +273,20 @@ export const DataTable = ({
}
: header;

const isSortable = !!getSortDirection(fieldKey);
const headerCellWrapperId = getHeaderWrapperId(fieldKey);

return (
<HeaderCell
data-testid={getDataTestId(`header-${fieldKey}`)}
key={fieldKey}
$clickable={clickable}
onClick={() => clickable && onHeaderClick?.(fieldKey)}
scope="col"
aria-sort={getHeaderAriaSort(fieldKey)}
style={style}
$isCheckbox={false}
>
<HeaderCellWrapper>
<HeaderCellWrapper id={headerCellWrapperId}>
{typeof label === "string" ? (
<Typography.BodyBL weight="bold">
{label}
Expand All @@ -247,6 +296,15 @@ export const DataTable = ({
)}
{renderSortedArrow(fieldKey)}
</HeaderCellWrapper>
{(clickable || isSortable) && (
<VisuallyHidden>
<button onClick={() => onHeaderClick?.(fieldKey)}>
{isSortable && "Sort "}
<span aria-labelledby={headerCellWrapperId} />
{isSortable && getSortButtonAriaLabel(fieldKey)}
</button>
</VisuallyHidden>
)}
</HeaderCell>
);
};
Expand Down Expand Up @@ -277,12 +335,15 @@ export const DataTable = ({
data-testid={getDataTestId("header-selection")}
$clickable={false}
$isCheckbox={true}
scope="col"
Comment thread
qroll marked this conversation as resolved.
>
<CheckBoxWrapper>
<VisuallyHidden>Row selection</VisuallyHidden>
Comment thread
qroll marked this conversation as resolved.
{enableSelectAll && (
<Checkbox
checked={isAllCheckboxSelected()}
indeterminate={isIndeterminateCheckbox()}
aria-label="Select all rows"
onClick={() => {
if (onSelectAll) {
onSelectAll(isAllCheckboxSelected());
Expand Down Expand Up @@ -314,8 +375,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))}
</BodyRow>
Expand All @@ -335,6 +395,7 @@ export const DataTable = ({
<BodyCell
data-testid={getDataTestId(`row-${cellId}`)}
key={cellId}
id={getCellId(rowId, fieldKey)}
style={style}
$isCheckbox={false}
>
Expand All @@ -350,7 +411,9 @@ export const DataTable = ({
);
};

const renderRowCheckBox = (rowId: string) => {
const renderRowCheckBox = (row: RowProps) => {
const rowId = row.id.toString();

return (
<BodyCell
data-testid={getDataTestId(`row-${rowId}-selection`)}
Expand All @@ -359,12 +422,14 @@ export const DataTable = ({
<CheckBoxWrapper>
<Checkbox
checked={isRowSelected(rowId)}
aria-labelledby={getRowCheckboxAriaLabelledBy(rowId)}
onClick={() => {
if (onSelect) {
onSelect(rowId, !isRowSelected(rowId));
}
}}
disabled={isDisabledRow(rowId)}
focusableWhenDisabled={isDisabledRow(rowId)}
/>
</CheckBoxWrapper>
</BodyCell>
Expand Down Expand Up @@ -404,7 +469,11 @@ export const DataTable = ({
return (
<tr>
<td colSpan={getTotalColumns()}>
<LoaderWrapper>
<LoaderWrapper
role="status"
aria-live="polite"
aria-label="Loading table"
>
{loadState === "loading" && <LoadingDotsSpinner />}
</LoaderWrapper>
</td>
Expand All @@ -431,7 +500,13 @@ export const DataTable = ({
<Typography.BodyMD weight="semibold">{`${count} item${
count > 1 ? "s" : ""
} selected`}</Typography.BodyMD>
<TextButton type="button" onClick={onClearSelectionClick}>
<TextButton
type="button"
aria-label={`Clear selection of ${count} item${
count === 1 ? "" : "s"
}`}
onClick={onClearSelectionClick}
>
Clear selection
</TextButton>
{actionBarContent}
Expand Down
5 changes: 5 additions & 0 deletions src/data-table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 8 additions & 1 deletion stories/data-table/doc-elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,24 @@ export const DataTableWithCustomHeight = ({
children,
}: Props) => {
const [height, setHeight] = useState<Height>("default");
const rowCountInputId = "data-table-row-count";

return (
<Container $height={height} key={height}>
<div>
<Typography.BodyBL>
Modify the options to view the scroll behaviour
</Typography.BodyBL>
<Typography.BodySM weight="semibold">
<Typography.BodySM
as="label"
htmlFor={rowCountInputId}
weight="semibold"
>
Number of rows
</Typography.BodySM>
<div>
<input
id={rowCountInputId}
type="range"
value={rowCount}
min={1}
Expand Down
6 changes: 6 additions & 0 deletions stories/data-table/props-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ const DATA: ApiTableSectionProps[] = [
"Specifies custom styles for the column header cell",
propTypes: ["CSSProperties"],
},
{
name: "keyColumn",
description:
"Used with enableMultiSelect. Marks this column as descriptive of the row and uses its value to label the row checkbox for screen readers",
propTypes: ["boolean"],
},
],
},
{
Expand Down
2 changes: 2 additions & 0 deletions stories/data-table/row-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ export const DATA_HEADERS = [
{
fieldKey: "status",
label: "Status",
keyColumn: true,
},
{
fieldKey: "resource",
label: "Resource",
keyColumn: true,
},
{
fieldKey: "time",
Expand Down
Loading