Skip to content

Commit c1551d0

Browse files
committed
feat: onCellClick with native-button cells; fix Firefox HOME in CI e2e (B09)
1 parent 1c7fcb2 commit c1551d0

9 files changed

Lines changed: 250 additions & 27 deletions

File tree

‎.ai/output/features/react-data-matrix-1.0.feature‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,24 @@ Feature: React Data Matrix 1.0
174174
Then every valid rating is still shown
175175
And a clear message names the rating that could not be placed
176176

177-
@planned @B09
177+
@B09
178178
Scenario: A developer reacts to a cell being chosen
179179
Given a matrix with a cell selection handler
180-
When a person chooses a cell by pointer or keyboard
180+
When a person chooses a cell by pointer, Enter or Space
181181
Then the handler receives that cell's rating, likelihood and consequence
182182

183+
@B09
184+
Scenario: Chosen cells are announced clearly
185+
Given a matrix with a cell selection handler
186+
Then every rating is a button named by its likelihood, consequence and rating
187+
And a matrix without a handler shows its ratings as plain text
188+
189+
@B09
190+
Scenario: Mistakes in the developer's handler are not hidden
191+
Given a cell selection handler that fails
192+
When a person chooses a cell
193+
Then the failure reaches the host application
194+
183195
@planned @B12
184196
Scenario: Every preset design is distinct
185197
Given all 26 preset designs

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ jobs:
5656
persist-credentials: false
5757
- run: npm ci
5858
- run: npm run test:e2e
59+
env:
60+
# GitHub sets HOME=/github/home (owned by another user) in container jobs;
61+
# Firefox refuses to run as root there. Playwright's documented workaround.
62+
HOME: /root
5963
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
6064
if: failure()
6165
with:

‎e2e/site.spec.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ test('the page never scrolls sideways; a wide matrix scrolls inside itself', asy
4343
expect(overflow).toBeLessThanOrEqual(0);
4444
});
4545

46+
test('a cell can be chosen by pointer, Enter or Space', async ({ page }) => {
47+
await page.goto('./');
48+
const status = page.getByRole('status');
49+
50+
await page
51+
.getByRole('button', { name: 'Likelihood Rare, Consequence Minor: low (1)' })
52+
.click();
53+
await expect(status).toHaveText('Rare × Minor: low (1). Business as usual.');
54+
55+
await page
56+
.getByRole('button', { name: 'Likelihood Almost Certain, Consequence Catastrophic: extreme (25)' })
57+
.focus();
58+
await page.keyboard.press('Enter');
59+
await expect(status).toHaveText(
60+
'Almost Certain × Catastrophic: extreme (25). Activity should not commence.'
61+
);
62+
63+
await page
64+
.getByRole('button', { name: 'Likelihood Possible, Consequence Moderate: medium (8)' })
65+
.focus();
66+
await page.keyboard.press('Space');
67+
await expect(status).toHaveText(
68+
'Possible × Moderate: medium (8). Requires routine to periodic monitoring.'
69+
);
70+
});
71+
4672
test('a matrix that scrolls sideways can be scrolled from the keyboard', async ({ page }) => {
4773
await page.goto('./');
4874
const matrix = page.locator('.rdm-root');

‎lib/components/MatrixRows.tsx‎

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import type { GridRow } from '../grid.js';
22
import type { SeverityColour } from '../theme/types.js';
3-
import type { MatrixDetail, MatrixSlotStyles } from '../types/index.js';
3+
import type { MatrixDetail, MatrixSlotStyles, ReactMatrixProps } from '../types/index.js';
44
import TableData from './TableData.js';
55

66
interface MatrixRowsProps {
7-
title: string;
7+
rowTitle: string;
8+
columnTitle: string;
89
columns: MatrixDetail[];
910
rows: GridRow[];
1011
tiers: ReadonlyMap<string, number>;
1112
/** Severity colour per value id; `null` when unstyled. */
1213
colours: ReadonlyMap<number, SeverityColour> | null;
1314
styles: MatrixSlotStyles;
15+
onCellClick?: ReactMatrixProps['onCellClick'];
1416
}
1517

16-
const MatrixRows = ({ title, columns, rows, tiers, colours, styles }: MatrixRowsProps) => (
18+
const MatrixRows = ({
19+
rowTitle,
20+
columnTitle,
21+
columns,
22+
rows,
23+
tiers,
24+
colours,
25+
styles,
26+
onCellClick,
27+
}: MatrixRowsProps) => (
1728
<tbody>
1829
{rows.map(({ detail, cells }, rowIndex) => (
1930
<tr key={detail.id} data-row={detail.likelihood}>
@@ -24,24 +35,38 @@ const MatrixRows = ({ title, columns, rows, tiers, colours, styles }: MatrixRows
2435
rowSpan={rows.length}
2536
style={styles.axisTitle}
2637
>
27-
{title}
38+
{rowTitle}
2839
</th>
2940
)}
3041
<th className="rdm-row-header" scope="row" style={styles.rowHeader}>
3142
{detail.row_header_title}
3243
<div className="rdm-subtitle">{detail.row_header_sub_title}</div>
3344
</th>
34-
{cells.map((value, columnIndex) => (
35-
<TableData
36-
key={value?.id ?? `empty-${columnIndex}`}
37-
value={value}
38-
row={detail.likelihood}
39-
column={columns[columnIndex].consequence}
40-
tier={value ? tiers.get(value.colour) : undefined}
41-
colours={value ? colours?.get(value.id) : undefined}
42-
style={styles.cell}
43-
/>
44-
))}
45+
{cells.map((value, columnIndex) => {
46+
const column = columns[columnIndex];
47+
const interactive = value && onCellClick;
48+
return (
49+
<TableData
50+
key={value?.id ?? `empty-${columnIndex}`}
51+
value={value}
52+
row={detail.likelihood}
53+
column={column.consequence}
54+
tier={value ? tiers.get(value.colour) : undefined}
55+
colours={value ? colours?.get(value.id) : undefined}
56+
style={styles.cell}
57+
onActivate={
58+
interactive
59+
? (event) => onCellClick(value, { row: detail, column, event })
60+
: undefined
61+
}
62+
accessibleName={
63+
interactive
64+
? `${rowTitle} ${detail.row_header_title}, ${columnTitle} ${column.header_title}: ${value.description} (${value.score_value})`
65+
: undefined
66+
}
67+
/>
68+
);
69+
})}
4570
</tr>
4671
))}
4772
</tbody>

‎lib/components/TableData.tsx‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties } from 'react';
1+
import type { CSSProperties, MouseEvent } from 'react';
22
import type { SeverityColour } from '../theme/types.js';
33
import type { MatrixValue } from '../types/index.js';
44

@@ -9,12 +9,25 @@ interface TableDataProps {
99
tier?: number;
1010
colours?: SeverityColour;
1111
style?: CSSProperties;
12+
/** Set when the consumer passed `onCellClick`: the cell becomes a native button. */
13+
onActivate?: (event: MouseEvent<HTMLButtonElement>) => void;
14+
/** Row, column and rating, so the button makes sense outside the table's context. */
15+
accessibleName?: string;
1216
}
1317

1418
// One matrix cell. The severity colour arrives as --rdm-cell-bg/--rdm-cell-fg,
1519
// consumed only by colour properties in the base stylesheet; `data-tier` lets
1620
// unstyled (e.g. strict-CSP) consumers colour cells from their own CSS.
17-
const TableData = ({ value, row, column, tier, colours, style }: TableDataProps) => {
21+
const TableData = ({
22+
value,
23+
row,
24+
column,
25+
tier,
26+
colours,
27+
style,
28+
onActivate,
29+
accessibleName,
30+
}: TableDataProps) => {
1831
if (!value) {
1932
return (
2033
<td className="rdm-cell rdm-cell-empty" data-row={row} data-col={column} style={style} />
@@ -24,6 +37,13 @@ const TableData = ({ value, row, column, tier, colours, style }: TableDataProps)
2437
const colourVariables = colours
2538
? { '--rdm-cell-bg': colours.bg, '--rdm-cell-fg': colours.fg }
2639
: undefined;
40+
const content = (
41+
<>
42+
<span className="rdm-cell-label">{value.description}</span>
43+
<br />
44+
<span className="rdm-cell-score">{`(${value.score_value})`}</span>
45+
</>
46+
);
2747

2848
return (
2949
<td
@@ -33,9 +53,19 @@ const TableData = ({ value, row, column, tier, colours, style }: TableDataProps)
3353
data-tier={tier}
3454
style={{ ...colourVariables, ...style }}
3555
>
36-
<span className="rdm-cell-label">{value.description}</span>
37-
<br />
38-
<span className="rdm-cell-score">{`(${value.score_value})`}</span>
56+
{onActivate ? (
57+
// No try/catch: errors from the consumer's handler must surface, not vanish.
58+
<button
59+
type="button"
60+
className="rdm-cell-button"
61+
aria-label={accessibleName}
62+
onClick={onActivate}
63+
>
64+
{content}
65+
</button>
66+
) : (
67+
content
68+
)}
3969
</td>
4070
);
4171
};

‎lib/index.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const ReactMatrix = ({
4444
unstyled = false,
4545
reverseMatrixValues = true,
4646
nonce,
47+
onCellClick,
4748
}: ReactMatrixProps) => {
4849
const grid = useMemo(
4950
() => buildGrid(data, { reverse: reverseMatrixValues }),
@@ -102,7 +103,9 @@ const ReactMatrix = ({
102103
styles={styles}
103104
/>
104105
<MatrixRows
105-
title={data.primary_row_header_title}
106+
rowTitle={data.primary_row_header_title}
107+
columnTitle={data.primary_header_title}
108+
onCellClick={onCellClick}
106109
columns={grid.columns}
107110
rows={grid.rows}
108111
tiers={scale.tiers}
@@ -117,6 +120,7 @@ const ReactMatrix = ({
117120
export default ReactMatrix;
118121
export { original } from './theme/presets/original.js';
119122
export type {
123+
CellClickContext,
120124
MatrixData,
121125
MatrixDetail,
122126
MatrixRootStyle,

‎lib/types/index.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties } from 'react';
1+
import type { CSSProperties, MouseEvent } from 'react';
22
import type { MatrixTheme } from '../theme/types.js';
33

44
export interface MatrixDetail {
@@ -48,6 +48,16 @@ export type MatrixSlot =
4848

4949
export type MatrixSlotStyles = Partial<Record<MatrixSlot, CSSProperties>>;
5050

51+
/** What `onCellClick` receives besides the cell itself. */
52+
export interface CellClickContext {
53+
/** The likelihood row the cell is in. */
54+
row: Readonly<MatrixDetail>;
55+
/** The consequence column the cell is in. */
56+
column: Readonly<MatrixDetail>;
57+
/** Also fired by Enter and Space: each cell is a native button when a handler is set. */
58+
event: MouseEvent<HTMLButtonElement>;
59+
}
60+
5161
/** Root inline style; also accepts theme variable overrides like `--rdm-radius`. */
5262
export type MatrixRootStyle = CSSProperties & { [variable: `--rdm-${string}`]: string };
5363

@@ -65,4 +75,10 @@ export interface ReactMatrixProps {
6575
reverseMatrixValues?: boolean;
6676
/** CSP nonce for the base <style> element. */
6777
nonce?: string;
78+
/**
79+
* Called when a person chooses a cell by pointer or keyboard. Without it, cells
80+
* are plain text. The data is the consumer's own and is rendered as text only;
81+
* never inject it as HTML in the handler.
82+
*/
83+
onCellClick?: (cell: Readonly<MatrixValue>, context: CellClickContext) => void;
6884
}

‎src/App.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
1+
import { useState } from "react";
12
import ReactMatrix, { type MatrixData } from "../lib";
23
import IconsRow from "./components/iconsRow";
34
import Footer from "./components/footer";
45

5-
// Demo page: the matrix with the default Original preset. The preset gallery
6-
// replaces this page later in the 1.0 work.
6+
// Demo page: the matrix with the default Original preset, showing the chosen
7+
// cell's response through `onCellClick`. The preset gallery replaces this page
8+
// later in the 1.0 work.
79
function App({ data }: { data: MatrixData }) {
10+
const [selection, setSelection] = useState("Choose a cell to see its response.");
11+
812
return (
913
<>
1014
<IconsRow />
11-
<ReactMatrix data={data} styles={{ table: { width: '70rem' } }} />
15+
<ReactMatrix
16+
data={data}
17+
styles={{ table: { width: '70rem' } }}
18+
onCellClick={(cell, { row, column }) =>
19+
setSelection(
20+
`${row.row_header_title} × ${column.header_title}: ${cell.description} (${cell.score_value}). ${cell.response}.`
21+
)
22+
}
23+
/>
24+
<p role="status" style={{ textAlign: 'center' }}>
25+
{selection}
26+
</p>
1227
<Footer />
1328
</>
1429
);

0 commit comments

Comments
 (0)