Skip to content
Merged
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 .changeset/fix-cui-307-table-headers-a11y.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clickhouse/click-ui': patch
---

`Table` header content for sortable columns, and for columns with an `onClick`, now renders as a `<button>`: keyboard users reach it with Tab and sort with Enter or Space, and the sorted column exposes `aria-sort`. A sorted header's accessible name no longer ends in "arrow-down"; the direction is in `aria-sort`. Column resizers report their width and range to assistive technology. Header labels of sortable or clickable columns must not contain interactive content. Headers marked `isSortable` on a table without `onSort` no longer show a pointer cursor. Visual: unchanged, except a focus ring on a focused sortable header.
14 changes: 0 additions & 14 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,5 @@
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"src/components/Table/Table.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
},
"jsx-a11y/no-noninteractive-tabindex": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
}
}
16 changes: 16 additions & 0 deletions src/components/Table/Table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,23 @@
}

.table__header-content_interactive {
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-align: inherit;
text-transform: inherit;
cursor: pointer;
word-spacing: inherit;
}

.table__header-content_interactive:focus-visible {
outline: 2px solid var(--click-global-color-outline-default);
Comment thread
XOP marked this conversation as resolved.
outline-offset: 2px;
}

.table__resizer {
Expand Down
139 changes: 138 additions & 1 deletion src/components/Table/Table.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent } from '@testing-library/react';
import { fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Table, TableProps } from '@/components/Table';
import { renderCUI } from '@/utils/test-utils';

Expand Down Expand Up @@ -245,4 +246,140 @@ describe('Table', () => {
expect(resizers[0]).toHaveAttribute('tabIndex', '0');
expect(resizers[1]).toHaveAttribute('tabIndex', '0');
});

describe('sortable headers', () => {
const sortableHeaders = [
{ label: 'Company', isSortable: true, sortDir: 'asc' as const },
{ label: 'Contact', isSortable: true },
{ label: 'Country' },
];

it('renders sortable headers as buttons named after the label', () => {
const { getByRole } = renderCUI(
<Table
headers={sortableHeaders}
rows={rows}
onSort={vi.fn()}
/>
);

expect(getByRole('button', { name: 'Company' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Contact' })).toBeInTheDocument();
const country = getByRole('columnheader', { name: 'Country' });
expect(within(country).queryByRole('button')).toBeNull();
});

it('renders no header button when onSort is missing', () => {
const { getAllByRole } = renderCUI(
<Table
headers={sortableHeaders}
rows={rows}
/>
);

getAllByRole('columnheader').forEach(header => {
expect(within(header).queryByRole('button')).toBeNull();
});
});

it('is reachable with Tab and sorts with Enter and Space', async () => {
const user = userEvent.setup();
const onSort = vi.fn();
const { getByRole } = renderCUI(
<Table
headers={sortableHeaders}
rows={rows}
onSort={onSort}
/>
);

await user.tab();
expect(getByRole('button', { name: 'Company' })).toHaveFocus();

await user.keyboard('{Enter}');
expect(onSort).toHaveBeenCalledTimes(1);
expect(onSort).toHaveBeenLastCalledWith('desc', sortableHeaders[0], 0);

await user.tab();
expect(getByRole('button', { name: 'Contact' })).toHaveFocus();

await user.keyboard(' ');
expect(onSort).toHaveBeenCalledTimes(2);
expect(onSort).toHaveBeenLastCalledWith('asc', sortableHeaders[1], 1);
});

it('exposes aria-sort on the sorted column only', () => {
const { getByRole } = renderCUI(
<Table
headers={sortableHeaders}
rows={rows}
onSort={vi.fn()}
/>
);

expect(getByRole('columnheader', { name: 'Company' })).toHaveAttribute(
'aria-sort',
'ascending'
);
expect(getByRole('columnheader', { name: 'Contact' })).not.toHaveAttribute(
'aria-sort'
);
expect(getByRole('columnheader', { name: 'Country' })).not.toHaveAttribute(
'aria-sort'
);
});

it('maps a descending sortDir to aria-sort="descending"', () => {
const { getByRole } = renderCUI(
<Table
headers={[{ label: 'Company', isSortable: true, sortDir: 'desc' }]}
rows={[]}
onSort={vi.fn()}
/>
);

expect(getByRole('columnheader', { name: 'Company' })).toHaveAttribute(
'aria-sort',
'descending'
);
});
});

it('names the header button with a consumer aria-label', () => {
const { getByRole } = renderCUI(
<Table
headers={[{ label: 'Company', isSortable: true, 'aria-label': 'Company name' }]}
rows={[]}
onSort={vi.fn()}
/>
);

expect(getByRole('button', { name: 'Company name' })).toBeInTheDocument();
expect(getByRole('columnheader', { name: 'Company name' })).toBeInTheDocument();
});

it('reports the column width and range on the resizer and updates it on ArrowRight', () => {
const rect = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 200 } as DOMRect);

try {
const { getAllByRole } = renderTable({ resizableColumns: true });
const resizer = getAllByRole('separator')[0];

expect(resizer).toHaveAttribute('tabIndex', '0');
expect(resizer).toHaveAttribute('aria-valuenow', '200');
expect(resizer).toHaveAttribute('aria-valuemin', '120');
expect(resizer).toHaveAttribute('aria-valuemax', '280');
expect(resizer).toHaveAttribute('aria-valuetext', '200 pixels');

fireEvent.keyDown(resizer, { key: 'ArrowRight' });

expect(resizer).toHaveAttribute('aria-valuenow', '202');
expect(resizer).toHaveAttribute('aria-valuetext', '202 pixels');
expect(resizer).toHaveAttribute('aria-valuemax', '280');
} finally {
rect.mockRestore();
}
});
});
Loading
Loading