From 1a98d4bbff22d2c86e45c943f296b68626c34a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Wed, 12 Aug 2026 04:15:15 +0100 Subject: [PATCH 1/2] Make table sorting keyboard accessible Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/Table.test.tsx | 33 +++++++++++ .../dashboard/components/table/Table.tsx | 55 +++++++++++++------ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 3dd0ae57..609ecc00 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -1,5 +1,6 @@ import { GQLRuleStatus } from '@/graphql/generated'; import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; @@ -102,6 +103,38 @@ describe('Table behavior', () => { ).toBeTruthy(); }); + it('makes sortable headers keyboard accessible and reports their sort direction', () => { + renderTable(); + + const nameHeader = screen.getByRole('columnheader', { name: /Name/ }); + const nameSortButton = within(nameHeader).getByRole('button', { + name: /Name/, + }); + const statusHeader = screen.getByRole('columnheader', { name: 'Status' }); + + expect(nameHeader.getAttribute('aria-sort')).toBe('none'); + expect(within(statusHeader).queryByRole('button')).toBeNull(); + expect(statusHeader.hasAttribute('aria-sort')).toBe(false); + + nameSortButton.focus(); + expect(document.activeElement).toBe(nameSortButton); + userEvent.type(nameSortButton, '{enter}', { skipClick: true }); + expect(renderedNames()).toEqual([ + 'Rendered Alpha', + 'Rendered Alpine', + 'Rendered Zulu', + ]); + expect(nameHeader.getAttribute('aria-sort')).toBe('ascending'); + + userEvent.type(nameSortButton, '{space}', { skipClick: true }); + expect(renderedNames()).toEqual([ + 'Rendered Zulu', + 'Rendered Alpine', + 'Rendered Alpha', + ]); + expect(nameHeader.getAttribute('aria-sort')).toBe('descending'); + }); + it('renders accessor values and sorts by raw values only on sortable headers', () => { renderTable(); diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 09f096dd..a1dce068 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -108,11 +108,36 @@ export default function Table>( ) : ( headerGroup.headers.map((header, index) => { const sorted = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); + const headerContent = header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + ); + const sortIcon = canSort ? ( + sorted === 'desc' ? ( + + ) : sorted === 'asc' ? ( + + ) : ( + + ) + ) : null; return ( >( : '' }`} > -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {header.column.getCanSort() ? ( - sorted === 'desc' ? ( - - ) : sorted === 'asc' ? ( - - ) : ( - - ) - ) : null} -
+ {canSort && !header.isPlaceholder ? ( + + ) : ( +
+ {headerContent} +
+ )} ); }) From e6b5a32c7ca63a11bc1cdcde09e3648762c7502a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Wed, 12 Aug 2026 04:21:20 +0100 Subject: [PATCH 2/2] Handle grouped table sort headers Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fec69-19ab-77e8-b6f0-85af1ed67eed --- .../dashboard/components/table/Table.test.tsx | 44 ++++++++++++++++++- .../dashboard/components/table/Table.tsx | 15 ++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/client/src/webpages/dashboard/components/table/Table.test.tsx b/client/src/webpages/dashboard/components/table/Table.test.tsx index 609ecc00..6540aa1f 100644 --- a/client/src/webpages/dashboard/components/table/Table.test.tsx +++ b/client/src/webpages/dashboard/components/table/Table.test.tsx @@ -103,6 +103,38 @@ describe('Table behavior', () => { ).toBeTruthy(); }); + it('keeps placeholder headers inert while grouped sortable headers stay accessible', () => { + const groupedColumns = [ + columns[0], + { + header: 'Details', + columns: [columns[1]], + }, + ] satisfies TableColumnDef[]; + renderTable(groupedColumns); + + const placeholderHeader = screen + .getAllByRole('columnheader') + .find((header) => header.textContent === ''); + expect(placeholderHeader).toBeTruthy(); + expect(within(placeholderHeader!).queryByRole('button')).toBeNull(); + expect(placeholderHeader!.hasAttribute('aria-sort')).toBe(false); + + const nameHeader = screen.getByRole('columnheader', { name: /Name/ }); + const nameSortButton = within(nameHeader).getByRole('button', { + name: /Name/, + }); + expect(nameHeader.getAttribute('aria-sort')).toBe('none'); + + nameSortButton.focus(); + userEvent.type(nameSortButton, '{enter}', { skipClick: true }); + expect(renderedNames()).toEqual([ + 'Rendered Alpha', + 'Rendered Alpine', + 'Rendered Zulu', + ]); + }); + it('makes sortable headers keyboard accessible and reports their sort direction', () => { renderTable(); @@ -124,7 +156,11 @@ describe('Table behavior', () => { 'Rendered Alpine', 'Rendered Zulu', ]); - expect(nameHeader.getAttribute('aria-sort')).toBe('ascending'); + expect( + screen + .getByRole('columnheader', { name: /Name/ }) + .getAttribute('aria-sort'), + ).toBe('ascending'); userEvent.type(nameSortButton, '{space}', { skipClick: true }); expect(renderedNames()).toEqual([ @@ -132,7 +168,11 @@ describe('Table behavior', () => { 'Rendered Alpine', 'Rendered Alpha', ]); - expect(nameHeader.getAttribute('aria-sort')).toBe('descending'); + expect( + screen + .getByRole('columnheader', { name: /Name/ }) + .getAttribute('aria-sort'), + ).toBe('descending'); }); it('renders accessor values and sorts by raw values only on sortable headers', () => { diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index a1dce068..130ab783 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -108,14 +108,15 @@ export default function Table>( ) : ( headerGroup.headers.map((header, index) => { const sorted = header.column.getIsSorted(); - const canSort = header.column.getCanSort(); + const isSortableHeader = + !header.isPlaceholder && header.column.getCanSort(); const headerContent = header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), ); - const sortIcon = canSort ? ( + const sortIcon = isSortableHeader ? ( sorted === 'desc' ? ( ) : sorted === 'asc' ? ( @@ -128,9 +129,13 @@ export default function Table>( >( : '' }`} > - {canSort && !header.isPlaceholder ? ( + {isSortableHeader ? (