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
33 changes: 25 additions & 8 deletions src/app/webhooks/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<>
<div>
Expand All @@ -152,6 +146,29 @@ export default function WebhooksClient() {
</IconButton>
</>
)}
renderCells={(hook, { requestRemove }) => [
<span key="url" className="break-all text-sm font-medium">
{hook.url}
</span>,
<div key="events" className="flex flex-wrap gap-1">
{hook.events.map((event) => (
<Badge key={event}>{event}</Badge>
))}
</div>,
<span key="registered" className="text-xs text-neutral-500">
<TimeAgo ts={hook.createdAt} />
</span>,
<IconButton key="actions" label="Remove webhook" onClick={requestRemove}>
×
</IconButton>,
]}
removeDialogTitle="Remove webhook?"
removeDialogConfirmLabel="Remove"
onRemove={(hook) =>
void apiDelete(`/api/v1/webhooks/${hook.id}`).then(() =>
hooks.refetch()
)
}
/>
<ConfirmDialog
open={confirmRegister}
Expand Down
56 changes: 56 additions & 0 deletions src/app/webhooks/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -706,4 +706,60 @@ describe('WebhooksPage', () => {
expect(timeEls.length).toBeGreaterThanOrEqual(2);
timeEls.forEach((el) => expect(el).toHaveAttribute('dateTime'));
});

// -------------------------------------------------------------------------
// A11Y: TABLE SEMANTICS
// -------------------------------------------------------------------------

it('renders a <caption> inside the webhooks table', async () => {
mockFetchSequence({ ok: true, body: { items: [HOOK_1] } });
render(<WebhooksPage />);
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(<WebhooksPage />);
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(<WebhooksPage />);
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 <table> when there are no webhooks', async () => {
mockFetchSequence({ ok: true, body: { items: [] } });
render(<WebhooksPage />);
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(<WebhooksPage />);
await waitFor(() =>
expect(screen.getByText('https://example.com/hook')).toBeInTheDocument()
);
const caption = document.querySelector('table caption');
expect(caption).toHaveClass('sr-only');
});
});
88 changes: 84 additions & 4 deletions src/components/ResourceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,18 @@ export type ResourceListProps<T> = {
* 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 `<li>`; defaults to the shared row layout. */
/**
* Render an array of `<td>` cells for a table row. Used together with
* `caption` and `tableHeaders` to produce a semantic `<table>` instead of
* the default `<ul>` list.
*/
renderCells?: (item: T, actions: ResourceRowActions) => ReactNode[];
/** Class string applied to every `<li>` or `<tr>`; defaults to the shared row layout. */
rowClassName?: string;
/** Title of the remove-confirmation dialog. */
removeDialogTitle: string;
Expand All @@ -36,6 +45,17 @@ export type ResourceListProps<T> = {
onRemove: (item: T) => void | Promise<void>;
/** Loading message, defaults to `Loading…`. */
loadingMessage?: string;
/**
* When provided, the list renders as a semantic `<table>` with this text
* as the `<caption>`. The caption is visually hidden but available to
* assistive technology.
*/
caption?: string;
/**
* Column header labels for the table. Each entry produces a `<th>` with
* `scope="col"` inside a `<thead>`. Required when `caption` is set.
*/
tableHeaders?: string[];
};

/**
Expand All @@ -45,19 +65,27 @@ export type ResourceListProps<T> = {
* `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 `<table>` with `<caption>`, `scope="col"` headers, and
* `scope="row"` on the first cell of each row — improving the experience
* for screen-reader users.
*/
export function ResourceList<T>({
items,
loading,
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<T>) {
const [pendingRemove, setPendingRemove] = useState<T | null>(null);

Expand All @@ -67,17 +95,69 @@ export function ResourceList<T>({
void onRemove(target!);
};

const useTable = Boolean(caption && tableHeaders && renderCells);

return (
<>
<div aria-live="polite" aria-atomic="true">
{loading && !items && <p>{loadingMessage}</p>}
{items && items.length === 0 && (
<p className="text-sm text-neutral-600">{emptyMessage}</p>
)}
{items && items.length > 0 && (
{items && items.length > 0 && useTable && (
<table className="w-full border-collapse">
<caption className="sr-only">{caption}</caption>
<thead>
<tr>
{tableHeaders!.map((header) => (
<th
key={header}
scope="col"
className="border-b border-neutral-200 px-3 py-2 text-left text-sm font-medium dark:border-neutral-800"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className={rowClassName}>
{items.map((item) => (
<tr key={getKey(item)}>
{renderCells!(item, {
requestRemove: () => setPendingRemove(item),
}).map((cell, i) =>
i === 0 ? (
<th
key={i}
scope="row"
className="border-b border-neutral-200 px-3 py-2 text-left text-sm font-medium dark:border-neutral-800"
>
{cell}
</th>
) : (
<td
key={i}
className="border-b border-neutral-200 px-3 py-2 text-sm dark:border-neutral-800"
>
{cell}
</td>
)
)}
</tr>
))}
</tbody>
</table>
)}
{items && items.length > 0 && !useTable && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{items.map((item) => (
<li key={getKey(item)} className={rowClassName}>
<li
key={getKey(item)}
className={
rowClassName ??
'flex items-center justify-between gap-3 py-3'
}
>
{renderRow(item, {
requestRemove: () => setPendingRemove(item),
})}
Expand Down
110 changes: 110 additions & 0 deletions src/components/__tests__/ResourceList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ComponentProps<typeof ResourceList<Sample>>> = {}
) {
return {
items: SAMPLES,
loading: false,
emptyMessage: 'Nothing here.',
getKey: (item: Sample) => item.id,
removeDialogTitle: 'Delete item?',
removeDialogConfirmLabel: 'Delete',
onRemove: jest.fn(),
renderRow: (item: Sample) => <span>{item.name}</span>,
caption: 'All items',
tableHeaders: TABLE_HEADERS,
renderCells: (item: Sample, { requestRemove }: { requestRemove: () => void }) => [
<span key="name">{item.name}</span>,
<button key="rm" type="button" onClick={requestRemove}>
Remove {item.name}
</button>,
],
...overrides,
};
}

it('renders a <table> when caption and renderCells are provided', () => {
render(<ResourceList {...tableProps()} />);
expect(document.querySelector('table')).toBeInTheDocument();
expect(document.querySelector('ul')).not.toBeInTheDocument();
});

it('renders the caption text inside a <caption> element', () => {
render(<ResourceList {...tableProps()} />);
const caption = document.querySelector('table caption');
expect(caption).toBeInTheDocument();
expect(caption).toHaveTextContent('All items');
});

it('renders the caption as visually hidden (sr-only)', () => {
render(<ResourceList {...tableProps()} />);
const caption = document.querySelector('table caption');
expect(caption).toHaveClass('sr-only');
});

it('renders column headers with scope="col"', () => {
render(<ResourceList {...tableProps()} />);
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(<ResourceList {...tableProps()} />);
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 <td> for non-first columns', () => {
render(<ResourceList {...tableProps()} />);
const tds = document.querySelectorAll('tbody td');
expect(tds.length).toBeGreaterThanOrEqual(2);
});

it('falls back to <ul> when caption is set but renderCells is missing', () => {
render(<ResourceList {...tableProps({ renderCells: undefined })} />);
expect(document.querySelector('ul')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});

it('falls back to <ul> when renderCells is set but caption is missing', () => {
render(<ResourceList {...tableProps({ caption: undefined })} />);
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(<ResourceList {...tableProps({ items: [] })} />);
expect(screen.getByText('Nothing here.')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});

it('still shows loading in table mode when items are null', () => {
render(<ResourceList {...tableProps({ items: null, loading: true })} />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});

it('opens the remove dialog from a table row cell', () => {
render(<ResourceList {...tableProps()} />);
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(<ResourceList {...tableProps({ onRemove })} />);
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]);
});
});