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
42 changes: 36 additions & 6 deletions src/DataTable/TableHeaderCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface TableHeaderCellProps {
render: (type: 'Header') => React.ReactNode;
/** Indicates whether the column is sorted in descending order */
isSortedDesc?: boolean;
/** Gets props related to sorting that will be passed to th */
/** Gets props related to sorting that will be passed to the sort button */
getSortByToggleProps?: (...args: any[]) => Record<string, any>;
/** Indicates whether a column is sortable */
canSort?: boolean;
Expand All @@ -48,14 +48,44 @@ function TableHeaderCell({
isSortedDesc = false,
headerClassName,
}: TableHeaderCellProps) {
const headerProps = getHeaderProps();
const toggleProps = canSort && getSortByToggleProps ? getSortByToggleProps() : {};
// Per WAI-ARIA APG sortable table guidance, only the actively sorted header should expose aria-sort.
let ariaSort: React.AriaAttributes['aria-sort'];
if (isSorted) {
ariaSort = isSortedDesc ? 'descending' : 'ascending';
}
const headerContentClassName = classNames('pgn__data-table-header-content', headerClassName);

return (
<th {...getHeaderProps(toggleProps)}>
<span className={classNames('d-flex align-items-center', headerClassName)}>
<span>{render('Header')}</span>
{canSort && <SortIndicator isSorted={isSorted} isSortedDesc={isSortedDesc || false} />}
</span>
<th
{...headerProps}
scope="col"
aria-sort={ariaSort}
className={classNames(
headerProps.className,
{ 'pgn__data-table-sortable-header': canSort },
)}
>
{canSort ? (
<button
{...toggleProps}
type="button"
className={classNames(
'pgn__data-table-sort-button',
toggleProps.className,
)}
>
<span className={headerContentClassName}>
<span>{render('Header')}</span>
<SortIndicator isSorted={isSorted} isSortedDesc={isSortedDesc || false} />
</span>
</button>
) : (
<span className={headerContentClassName}>
<span>{render('Header')}</span>
</span>
)}
</th>
);
}
Expand Down
30 changes: 30 additions & 0 deletions src/DataTable/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,36 @@
background-color: var(--pgn-color-light-300);
padding: var(--pgn-spacing-data-table-padding-cell);
text-align: start;

// The button owns sortable header padding so the full visible header remains the sort hit target.
&.pgn__data-table-sortable-header {
padding: 0;
position: relative;
}
}

.pgn__data-table-header-content {
display: flex;
align-items: center;
}

.pgn__data-table-sort-button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font: inherit;
padding: var(--pgn-spacing-data-table-padding-cell);
text-align: inherit;
width: 100%;
box-sizing: border-box;

// Extend pointer hit testing to the full sortable th when row height is set by another header cell.
&::before {
content: "";
position: absolute;
inset: 0;
}
}

td {
Expand Down
34 changes: 33 additions & 1 deletion src/DataTable/tests/DataTable.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useContext } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import {
render, screen, waitFor, within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as reactTable from 'react-table';
import { IntlProvider } from 'react-intl';
Expand Down Expand Up @@ -145,6 +147,36 @@ describe('<DataTable />', () => {
expect(screen.getAllByRole('row')).toHaveLength(props.data.length + 1); // (need + 1 to include header row)
});

it('sorts rows when activating a sortable header button', async () => {
const getNameColumnValues = () => screen.getAllByRole('row')
.slice(1)
.map(row => within(row).getAllByRole('cell')[0].textContent);

render(<DataTableWrapper {...props} isSortable />);

expect(getNameColumnValues()).toEqual([
'Lil Bub',
'Grumpy Cat',
'Smoothie',
'Maru',
'Keyboard Cat',
'Long Cat',
'Zeno',
]);

await userEvent.click(screen.getByRole('button', { name: 'Name' }));

await waitFor(() => expect(getNameColumnValues()).toEqual([
'Grumpy Cat',
'Keyboard Cat',
'Lil Bub',
'Long Cat',
'Maru',
'Smoothie',
'Zeno',
]));
});

it('displays a table footer', () => {
render(<DataTableWrapper {...props} />);
expect(screen.getByTestId('table-footer')).toBeInTheDocument();
Expand Down
95 changes: 88 additions & 7 deletions src/DataTable/tests/TableHeaderCell.test.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import TableHeaderCell from '../TableHeaderCell';

const sortByToggleProps = { foo: 'bar' };
const sortByToggleProps = { 'data-sort-prop': 'bar' };
const props = {
getHeaderProps: () => ({ className: 'red' }),
render: () => 'Title',
Expand All @@ -20,21 +21,41 @@ function FakeTable({ ...rest }) {

describe('<TableHeaderCell />', () => {
describe('unsorted', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
const innerCell = cell.firstChild;

it('renders a table header cell', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
expect(cell).toBeInTheDocument();
});

it('adds props to the cell', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
expect(cell.className).toBe('red');
});

it('adds column scope to the cell', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
expect(cell).toHaveAttribute('scope', 'col');
});

it('adds the headerClassName to inner span', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
const innerCell = cell.firstChild;
expect(innerCell.className).toContain(props.headerClassName);
});

it('does not render a button for non-sortable headers', () => {
render(<FakeTable {...props} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('does not add sortable header styling to non-sortable headers', () => {
render(<FakeTable {...props} />);
const cell = screen.getByRole('columnheader');
expect(cell).not.toHaveClass('pgn__data-table-sortable-header');
});
});

describe('with sorting', () => {
Expand All @@ -56,10 +77,70 @@ describe('<TableHeaderCell />', () => {
expect(sortIndicator).toBeInTheDocument();
});

it('adds the toggle props to the header props if toggle props are available', () => {
it('renders the sort toggle as a button', () => {
render(<FakeTable {...props} canSort />);
expect(screen.getByRole('button', { name: 'Title' })).toBeInTheDocument();
});

it('adds sortable header styling to preserve the sort button hit target', () => {
render(<FakeTable {...props} canSort />);
const cell = screen.getByRole('columnheader');
const button = screen.getByRole('button', { name: 'Title' });
expect(cell).toHaveClass('pgn__data-table-sortable-header');
expect(button).toHaveClass('pgn__data-table-sort-button');
});

it('adds headerClassName to sortable header content', () => {
render(<FakeTable {...props} canSort />);
const button = screen.getByRole('button', { name: 'Title' });
expect(button).not.toHaveClass(props.headerClassName);
expect(button.firstChild).toHaveClass(props.headerClassName);
});

it('adds ascending sort state to the header cell when sorted ascending', () => {
render(<FakeTable {...props} canSort isSorted />);
const cell = screen.getByRole('columnheader');
expect(cell).toHaveAttribute('aria-sort', 'ascending');
});

it('adds descending sort state to the header cell when sorted descending', () => {
render(<FakeTable {...props} canSort isSorted isSortedDesc />);
const cell = screen.getByRole('columnheader');
expect(cell).toHaveAttribute('aria-sort', 'descending');
});

it('does not add inactive sort state to unsorted header cells', () => {
render(<FakeTable {...props} canSort />);
const cell = screen.getByRole('columnheader');
expect(cell).not.toHaveAttribute('aria-sort');
});

it('adds the toggle props to the sort button if toggle props are available', () => {
render(<FakeTable {...props} canSort />);
const button = screen.getByRole('button', { name: 'Title' });
expect(button).toHaveAttribute('data-sort-prop', sortByToggleProps['data-sort-prop']);
});

it('does not pass toggle props to the header props', () => {
const headerPropsSpy = jest.fn().mockReturnValueOnce({});
render(<FakeTable {...props} canSort getHeaderProps={headerPropsSpy} />);
expect(headerPropsSpy).toHaveBeenCalledWith(sortByToggleProps);
expect(headerPropsSpy).toHaveBeenCalledWith();
});

it('makes sortable headers keyboard reachable and operable', async () => {
const user = userEvent.setup();
const handleSort = jest.fn();
render(<FakeTable {...props} canSort getSortByToggleProps={() => ({ onClick: handleSort })} />);
const button = screen.getByRole('button', { name: 'Title' });

await user.tab();
expect(button).toHaveFocus();

await user.keyboard('{Enter}');
expect(handleSort).toHaveBeenCalledTimes(1);

await user.keyboard(' ');
expect(handleSort).toHaveBeenCalledTimes(2);
});
});
});