diff --git a/src/app/webhooks/Client.tsx b/src/app/webhooks/Client.tsx index dc91f80..f3115c1 100644 --- a/src/app/webhooks/Client.tsx +++ b/src/app/webhooks/Client.tsx @@ -126,14 +126,8 @@ export default function WebhooksClient() { loading={loading} emptyMessage="No webhooks registered." getKey={(hook) => hook.id} - rowClassName="flex items-center justify-between gap-3 py-3" - removeDialogTitle="Remove webhook?" - removeDialogConfirmLabel="Remove" - onRemove={(hook) => - void apiDelete(`/api/v1/webhooks/${hook.id}`).then(() => - hooks.refetch() - ) - } + caption="Registered webhooks" + tableHeaders={['URL', 'Events', 'Registered', 'Actions']} renderRow={(hook, { requestRemove }) => ( <>
@@ -152,6 +146,29 @@ export default function WebhooksClient() { )} + renderCells={(hook, { requestRemove }) => [ + + {hook.url} + , +
+ {hook.events.map((event) => ( + {event} + ))} +
, + + + , + + × + , + ]} + removeDialogTitle="Remove webhook?" + removeDialogConfirmLabel="Remove" + onRemove={(hook) => + void apiDelete(`/api/v1/webhooks/${hook.id}`).then(() => + hooks.refetch() + ) + } /> { expect(timeEls.length).toBeGreaterThanOrEqual(2); timeEls.forEach((el) => expect(el).toHaveAttribute('dateTime')); }); + + // ------------------------------------------------------------------------- + // A11Y: TABLE SEMANTICS + // ------------------------------------------------------------------------- + + it('renders a inside the webhooks table', async () => { + mockFetchSequence({ ok: true, body: { items: [HOOK_1] } }); + render(); + await waitFor(() => + expect(screen.getByText('https://example.com/hook')).toBeInTheDocument() + ); + const caption = document.querySelector('table caption'); + expect(caption).toBeInTheDocument(); + expect(caption).toHaveTextContent(/webhooks/i); + }); + + it('renders column headers with scope="col"', async () => { + mockFetchSequence({ ok: true, body: { items: [HOOK_1] } }); + render(); + await waitFor(() => + expect(screen.getByText('https://example.com/hook')).toBeInTheDocument() + ); + const colHeaders = document.querySelectorAll('thead th[scope="col"]'); + expect(colHeaders.length).toBeGreaterThanOrEqual(1); + colHeaders.forEach((th) => expect(th).toHaveAttribute('scope', 'col')); + }); + + it('renders row header cells with scope="row"', async () => { + mockFetchSequence({ ok: true, body: { items: [HOOK_1] } }); + render(); + await waitFor(() => + expect(screen.getByText('https://example.com/hook')).toBeInTheDocument() + ); + const rowHeaders = document.querySelectorAll('tbody th[scope="row"]'); + expect(rowHeaders.length).toBeGreaterThanOrEqual(1); + rowHeaders.forEach((th) => expect(th).toHaveAttribute('scope', 'row')); + }); + + it('does not render a when there are no webhooks', async () => { + mockFetchSequence({ ok: true, body: { items: [] } }); + render(); + await waitFor(() => + expect(screen.getByText(/No webhooks registered/i)).toBeInTheDocument() + ); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('renders the caption as visually hidden (sr-only)', async () => { + mockFetchSequence({ ok: true, body: { items: [HOOK_1] } }); + render(); + await waitFor(() => + expect(screen.getByText('https://example.com/hook')).toBeInTheDocument() + ); + const caption = document.querySelector('table caption'); + expect(caption).toHaveClass('sr-only'); + }); }); diff --git a/src/components/ResourceList.tsx b/src/components/ResourceList.tsx index d05f447..5053085 100644 --- a/src/components/ResourceList.tsx +++ b/src/components/ResourceList.tsx @@ -22,9 +22,18 @@ export type ResourceListProps = { * Render the inner content of a row. Receives the item and the shared * `requestRemove` action so the row's remove control can open the standard * confirmation dialog. + * + * Used in list mode (default). When `caption` is set and `renderCells` is + * provided, this prop is ignored. */ renderRow: (item: T, actions: ResourceRowActions) => ReactNode; - /** Class string applied to every `
  • `; defaults to the shared row layout. */ + /** + * Render an array of `
  • ` cells for a table row. Used together with + * `caption` and `tableHeaders` to produce a semantic `` instead of + * the default `
      ` list. + */ + renderCells?: (item: T, actions: ResourceRowActions) => ReactNode[]; + /** Class string applied to every `
    • ` or `
    `; defaults to the shared row layout. */ rowClassName?: string; /** Title of the remove-confirmation dialog. */ removeDialogTitle: string; @@ -36,6 +45,17 @@ export type ResourceListProps = { onRemove: (item: T) => void | Promise; /** Loading message, defaults to `Loading…`. */ loadingMessage?: string; + /** + * When provided, the list renders as a semantic `
    ` with this text + * as the ``. Required when `caption` is set. + */ + tableHeaders?: string[]; }; /** @@ -45,6 +65,11 @@ export type ResourceListProps = { * `divide-y` list markup, and the remove-confirmation dialog + state machine. * Page clients keep ownership of their create forms (which differ in fields * and pre-submit confirmation) and pass the per-row content via `renderRow`. + * + * When `caption` and `tableHeaders` are provided the component renders a + * semantic `
    `. The caption is visually hidden but available to + * assistive technology. + */ + caption?: string; + /** + * Column header labels for the table. Each entry produces a `
    ` with + * `scope="col"` inside a `
    ` with `
    `, `scope="col"` headers, and + * `scope="row"` on the first cell of each row — improving the experience + * for screen-reader users. */ export function ResourceList({ items, @@ -52,12 +77,15 @@ export function ResourceList({ emptyMessage, getKey, renderRow, - rowClassName = 'flex items-center justify-between gap-3 py-3', + renderCells, + rowClassName, removeDialogTitle, removeDialogConfirmLabel, removeDialogTone = 'danger', onRemove, loadingMessage = 'Loading…', + caption, + tableHeaders, }: ResourceListProps) { const [pendingRemove, setPendingRemove] = useState(null); @@ -67,6 +95,8 @@ export function ResourceList({ void onRemove(target!); }; + const useTable = Boolean(caption && tableHeaders && renderCells); + return ( <>
    @@ -74,10 +104,60 @@ export function ResourceList({ {items && items.length === 0 && (

    {emptyMessage}

    )} - {items && items.length > 0 && ( + {items && items.length > 0 && useTable && ( + + + + + {tableHeaders!.map((header) => ( + + ))} + + + + {items.map((item) => ( + + {renderCells!(item, { + requestRemove: () => setPendingRemove(item), + }).map((cell, i) => + i === 0 ? ( + + ) : ( + + ) + )} + + ))} + +
    {caption}
    + {header} +
    + {cell} + + {cell} +
    + )} + {items && items.length > 0 && !useTable && (
      {items.map((item) => ( -
    • +
    • {renderRow(item, { requestRemove: () => setPendingRemove(item), })} diff --git a/src/components/__tests__/ResourceList.test.tsx b/src/components/__tests__/ResourceList.test.tsx index 320c27d..10bc2cd 100644 --- a/src/components/__tests__/ResourceList.test.tsx +++ b/src/components/__tests__/ResourceList.test.tsx @@ -130,3 +130,113 @@ describe('ResourceList', () => { expect(screen.getByRole('button', { name: /drop/i })).toBeInTheDocument(); }); }); + +describe('ResourceList — table mode', () => { + const TABLE_HEADERS = ['Name', 'Action']; + + function tableProps( + overrides: Partial>> = {} + ) { + return { + items: SAMPLES, + loading: false, + emptyMessage: 'Nothing here.', + getKey: (item: Sample) => item.id, + removeDialogTitle: 'Delete item?', + removeDialogConfirmLabel: 'Delete', + onRemove: jest.fn(), + renderRow: (item: Sample) => {item.name}, + caption: 'All items', + tableHeaders: TABLE_HEADERS, + renderCells: (item: Sample, { requestRemove }: { requestRemove: () => void }) => [ + {item.name}, + , + ], + ...overrides, + }; + } + + it('renders a when caption and renderCells are provided', () => { + render(); + expect(document.querySelector('table')).toBeInTheDocument(); + expect(document.querySelector('ul')).not.toBeInTheDocument(); + }); + + it('renders the caption text inside a
      element', () => { + render(); + const caption = document.querySelector('table caption'); + expect(caption).toBeInTheDocument(); + expect(caption).toHaveTextContent('All items'); + }); + + it('renders the caption as visually hidden (sr-only)', () => { + render(); + const caption = document.querySelector('table caption'); + expect(caption).toHaveClass('sr-only'); + }); + + it('renders column headers with scope="col"', () => { + render(); + const colHeaders = document.querySelectorAll('thead th[scope="col"]'); + expect(colHeaders).toHaveLength(2); + expect(colHeaders[0]).toHaveTextContent('Name'); + expect(colHeaders[1]).toHaveTextContent('Action'); + }); + + it('renders the first cell of each row as a row header with scope="row"', () => { + render(); + const rowHeaders = document.querySelectorAll('tbody th[scope="row"]'); + expect(rowHeaders).toHaveLength(2); + expect(rowHeaders[0]).toHaveTextContent('Alpha'); + expect(rowHeaders[1]).toHaveTextContent('Beta'); + }); + + it('renders data cells as
      for non-first columns', () => { + render(); + const tds = document.querySelectorAll('tbody td'); + expect(tds.length).toBeGreaterThanOrEqual(2); + }); + + it('falls back to
        when caption is set but renderCells is missing', () => { + render(); + expect(document.querySelector('ul')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('falls back to
          when renderCells is set but caption is missing', () => { + render(); + expect(document.querySelector('ul')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('still shows the empty message in table mode when items are empty', () => { + render(); + expect(screen.getByText('Nothing here.')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('still shows loading in table mode when items are null', () => { + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(document.querySelector('table')).not.toBeInTheDocument(); + }); + + it('opens the remove dialog from a table row cell', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /remove alpha/i })); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent('Delete item?'); + }); + + it('calls onRemove when removal is confirmed from a table row', async () => { + const onRemove = jest.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole('button', { name: /remove alpha/i })); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + await waitFor(() => expect(onRemove).toHaveBeenCalledTimes(1)); + expect(onRemove).toHaveBeenCalledWith(SAMPLES[0]); + }); +});