Skip to content

Commit 60adf38

Browse files
committed
feat: implement InspectDialog and TweakPanel for design customization in gallery
1 parent e52bb2d commit 60adf38

10 files changed

Lines changed: 710 additions & 3 deletions

File tree

‎e2e/a11y.spec.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ const openPage = async (page: Page, path: string) => {
3838
.toBe(true);
3939
};
4040

41+
// The inspect dialog is the only interactive matrix on the gallery, and the only
42+
// place tweak controls exist, so it is scanned as its own state of the page.
43+
// (Scheduled for S10c, pulled forward to the batch that introduced it.)
44+
test('the open inspect dialog has no WCAG 2.2 A/AA violations', async ({ page }) => {
45+
await openPage(page, './');
46+
await page.getByRole('button', { name: 'Inspect Noir' }).click();
47+
await expect(page.getByRole('dialog', { name: 'Noir design' })).toBeVisible();
48+
49+
const allButContrast = await new AxeBuilder({ page })
50+
.withTags(WCAG_TAGS)
51+
.disableRules(['color-contrast'])
52+
.analyze();
53+
const contrast = await new AxeBuilder({ page })
54+
.withRules(['color-contrast'])
55+
.exclude(DATA_COLOURED_CELLS)
56+
.analyze();
57+
58+
expect(summarise([...allButContrast.violations, ...contrast.violations])).toEqual([]);
59+
});
60+
4161
for (const [label, path] of PAGES) {
4262
test(`the ${label} has no WCAG 2.2 A/AA violations`, async ({ page }) => {
4363
await openPage(page, path);

‎e2e/gallery.spec.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ test('shows every design as a live preview card', async ({ page }) => {
2626
test('previews are not interactive, so the grid adds no cell tab stops', async ({ page }) => {
2727
await page.goto(GALLERY);
2828

29-
await expect(page.getByRole('list', { name: 'Designs' }).getByRole('button')).toHaveCount(0);
29+
// Each card has one Inspect button; the 26 × 25 preview cells add none.
30+
const { total } = await shownCount(page);
31+
const grid = page.getByRole('list', { name: 'Designs' });
32+
await expect(grid.locator('.rdm-root button')).toHaveCount(0);
33+
await expect(grid.getByRole('button')).toHaveCount(total);
3034
});
3135

3236
test('filter chips narrow the designs and are kept in the URL', async ({ page }) => {

‎e2e/inspect.spec.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { expect, test, type Page } from '@playwright/test';
2+
3+
const GALLERY = './';
4+
5+
// Noir is a palette design with square corners and filled cells, so every tweak
6+
// below is a visible change from how it comes.
7+
const openNoir = async (page: Page) => {
8+
await page.goto(GALLERY);
9+
await page.getByRole('button', { name: 'Inspect Noir' }).click();
10+
const dialog = page.getByRole('dialog', { name: 'Noir design' });
11+
await expect(dialog).toBeVisible();
12+
return dialog;
13+
};
14+
15+
test('a design opens large, with the code needed to use it', async ({ page }) => {
16+
const dialog = await openNoir(page);
17+
18+
await expect(dialog.getByRole('table', { name: /React Matrix/ })).toBeVisible();
19+
await expect(dialog.locator('.tweak-snippet')).toHaveText(
20+
"import ReactMatrix, { noir } from 'react-data-matrix';\n\n" +
21+
'<ReactMatrix data={data} theme={noir} />'
22+
);
23+
// The token table is generated from the theme being rendered, so it cannot
24+
// drift from it.
25+
await expect(dialog.getByRole('row', { name: /^cellVariant/ })).toContainText('fill');
26+
27+
await page.keyboard.press('Escape');
28+
await expect(dialog).toBeHidden();
29+
});
30+
31+
test('tweaking a design changes both the preview and the snippet', async ({ page }) => {
32+
const dialog = await openNoir(page);
33+
const cell = dialog.locator('td.rdm-cell').first();
34+
const before = await cell.evaluate((element) => getComputedStyle(element).borderRadius);
35+
36+
await dialog.getByRole('radio', { name: 'Pill' }).check();
37+
await dialog.getByRole('radio', { name: 'Score' }).check();
38+
39+
await expect(dialog.locator('.tweak-snippet')).toContainText(
40+
"theme={{ ...noir, radius: '999px', emphasis: 'score' }}"
41+
);
42+
expect(await cell.evaluate((element) => getComputedStyle(element).borderRadius)).not.toBe(before);
43+
await expect(dialog.getByRole('row', { name: /^radius/ })).toContainText('999px');
44+
45+
// "As it comes" must return the design to its published form.
46+
await dialog.getByRole('radio', { name: 'As it comes' }).first().check();
47+
await expect(dialog.locator('.tweak-snippet')).toContainText("theme={{ ...noir, emphasis:");
48+
await expect(dialog.locator('.tweak-snippet')).not.toContainText('radius');
49+
});
50+
51+
test('choosing cells shows what the click handler receives, newest first', async ({ page }) => {
52+
const dialog = await openNoir(page);
53+
const log = dialog.locator('.inspect-log li');
54+
55+
await expect(log).toHaveCount(0);
56+
await dialog.getByRole('button', { name: /Likelihood Rare, Consequence Minor/ }).click();
57+
await expect(log).toHaveText(['Rare × Minor — low (1)']);
58+
59+
await dialog
60+
.getByRole('button', { name: /Likelihood Almost Certain, Consequence Catastrophic/ })
61+
.focus();
62+
await page.keyboard.press('Enter');
63+
await expect(log.first()).toHaveText('Almost Certain × Catastrophic — extreme (25)');
64+
65+
// Only the last five are kept, so the log cannot grow without bound.
66+
for (const name of await dialog.getByRole('button', { name: /Likelihood/ }).all()) {
67+
await name.click();
68+
}
69+
await expect(log).toHaveCount(5);
70+
});
71+
72+
test('the inspected matrix is shown whole on a desktop screen', async ({ page }) => {
73+
test.skip((page.viewportSize()?.width ?? 0) < 1024, 'phones scroll the matrix inside the dialog');
74+
await openNoir(page);
75+
76+
// Either side of the one-column/two-column split: a side panel that leaves the
77+
// matrix too little room clips it, which is how it shipped the first time.
78+
for (const width of [1280, 1360, 1600, 2400]) {
79+
await page.setViewportSize({ width, height: 900 });
80+
const clipped = await page
81+
.locator('.inspect-preview .rdm-root')
82+
.evaluate((root) => root.scrollWidth > root.clientWidth);
83+
expect(clipped, `clipped at ${width}px`).toBe(false);
84+
}
85+
});
86+
87+
test('the snippet can be copied to the clipboard', async ({ page, browserName, context }) => {
88+
test.skip(browserName !== 'chromium', 'clipboard permissions are Chromium-only in Playwright');
89+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
90+
const dialog = await openNoir(page);
91+
92+
await dialog.getByRole('button', { name: 'Copy' }).click();
93+
94+
await expect(dialog.getByRole('status')).toHaveText('Copied');
95+
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
96+
expect(clipboard).toContain('<ReactMatrix data={data} theme={noir} />');
97+
});
98+
99+
test('each design opens with its own tokens and an empty log', async ({ page }) => {
100+
const noir = await openNoir(page);
101+
await noir.getByRole('button', { name: /Likelihood Rare, Consequence Minor/ }).click();
102+
await expect(noir.locator('.inspect-log li')).toHaveCount(1);
103+
await page.keyboard.press('Escape');
104+
105+
await page.getByRole('button', { name: 'Inspect Aurora' }).click();
106+
const aurora = page.getByRole('dialog', { name: 'Aurora design' });
107+
108+
await expect(aurora.locator('.tweak-snippet')).toContainText('theme={aurora}');
109+
await expect(aurora.locator('.inspect-log li')).toHaveCount(0);
110+
});

‎src/gallery/Gallery.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { presets, type MatrixData, type PresetName } from '../../lib';
33
import { catalog } from './catalog';
44
import FilterChips from './FilterChips';
55
import { matchesFilters, readFilters, writeFilters, type GalleryFilters } from './filters';
6+
import InspectDialog from './InspectDialog';
67
import PresetCard from './PresetCard';
78
import './gallery.css';
89

@@ -12,6 +13,7 @@ const NAMES = Object.keys(presets) as PresetName[];
1213
// grid instead of hiding designs behind a dropdown.
1314
const Gallery = ({ data }: { data: MatrixData }) => {
1415
const [filters, setFilters] = useState<GalleryFilters>(() => readFilters(location.search));
16+
const [inspected, setInspected] = useState<PresetName | null>(null);
1517

1618
useEffect(() => {
1719
history.replaceState(
@@ -47,11 +49,27 @@ const Gallery = ({ data }: { data: MatrixData }) => {
4749
<ul className="gallery-grid" aria-label="Designs">
4850
{visible.map((name) => (
4951
<li key={name}>
50-
<PresetCard theme={presets[name]} info={catalog[name]} data={data} />
52+
<PresetCard
53+
theme={presets[name]}
54+
info={catalog[name]}
55+
data={data}
56+
onInspect={() => setInspected(name)}
57+
/>
5158
</li>
5259
))}
5360
</ul>
5461
)}
62+
{inspected && (
63+
// Remounted per design, so tweaks and the event log start clean.
64+
<InspectDialog
65+
key={inspected}
66+
preset={inspected}
67+
theme={presets[inspected]}
68+
info={catalog[inspected]}
69+
data={data}
70+
onClose={() => setInspected(null)}
71+
/>
72+
)}
5573
</main>
5674
);
5775
};

‎src/gallery/InspectDialog.tsx‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import ReactMatrix, { type MatrixData, type MatrixTheme } from '../../lib';
3+
import type { PresetInfo } from './catalog';
4+
import TweakPanel from './TweakPanel';
5+
import { applyTweaks, type Tweaks } from './tweaks';
6+
7+
interface InspectDialogProps {
8+
/** The preset's export name, used in the copyable snippet. */
9+
preset: string;
10+
theme: MatrixTheme;
11+
info: PresetInfo;
12+
data: MatrixData;
13+
onClose: () => void;
14+
}
15+
16+
const LOG_LIMIT = 5;
17+
18+
interface LoggedClick {
19+
id: number;
20+
text: string;
21+
}
22+
23+
// The gallery's previews are plain text; this is the one place a design is
24+
// shown large, tweakable and interactive.
25+
const InspectDialog = ({ preset, theme, info, data, onClose }: InspectDialogProps) => {
26+
const dialog = useRef<HTMLDialogElement>(null);
27+
// The log keeps only the last five entries, so its length is not a usable key.
28+
const nextId = useRef(0);
29+
const [tweaks, setTweaks] = useState<Tweaks>({});
30+
const [log, setLog] = useState<LoggedClick[]>([]);
31+
32+
// `showModal` is what makes Escape, the focus trap and inertness work; it can
33+
// only be called imperatively, and never during render.
34+
useEffect(() => {
35+
dialog.current?.showModal();
36+
}, []);
37+
38+
const applied = applyTweaks({ theme, tweaks });
39+
40+
return (
41+
<dialog className="inspect" ref={dialog} onClose={onClose} aria-label={`${theme.name} design`}>
42+
<div className="inspect-head">
43+
<div>
44+
<h2>{theme.name}</h2>
45+
<p>{info.description}</p>
46+
</div>
47+
<button type="button" onClick={() => dialog.current?.close()}>
48+
Close
49+
</button>
50+
</div>
51+
52+
<div className="inspect-body">
53+
<div className="inspect-preview">
54+
<ReactMatrix
55+
data={data}
56+
theme={applied}
57+
onCellClick={(cell, { row, column }) =>
58+
setLog((entries) =>
59+
[
60+
{
61+
id: nextId.current++,
62+
text: `${row.row_header_title} × ${column.header_title} — ${cell.description} (${cell.score_value})`,
63+
},
64+
...entries,
65+
].slice(0, LOG_LIMIT)
66+
)
67+
}
68+
/>
69+
<h3>Cell selections</h3>
70+
<p className="inspect-hint">
71+
Choose a cell with a pointer, Enter or Space: this is what{' '}
72+
<code>onCellClick</code> receives.
73+
</p>
74+
{/* The live region is this wrapper, so it stays in place while its
75+
contents change from the empty state to the list. */}
76+
<div className="inspect-log" aria-live="polite">
77+
{log.length === 0 ? (
78+
<p>Nothing chosen yet.</p>
79+
) : (
80+
<ol>
81+
{log.map((entry) => (
82+
<li key={entry.id}>{entry.text}</li>
83+
))}
84+
</ol>
85+
)}
86+
</div>
87+
</div>
88+
89+
<div className="inspect-side">
90+
<TweakPanel preset={preset} tweaks={tweaks} onChange={setTweaks} />
91+
92+
<h3>Tokens</h3>
93+
<table className="inspect-tokens">
94+
<caption>Every value in this design, as it is being rendered.</caption>
95+
<tbody>
96+
{Object.entries(applied).map(([token, value]) => (
97+
<tr key={token}>
98+
<th scope="row">{token}</th>
99+
<td>
100+
{Array.isArray(value) ? (
101+
<span className="inspect-swatches">
102+
{value.map((step: { bg: string; fg: string }) => (
103+
<span
104+
key={step.bg}
105+
style={{ backgroundColor: step.bg, color: step.fg }}
106+
title={`${step.fg} on ${step.bg}`}
107+
>
108+
A
109+
</span>
110+
))}
111+
</span>
112+
) : (
113+
String(value)
114+
)}
115+
</td>
116+
</tr>
117+
))}
118+
</tbody>
119+
</table>
120+
</div>
121+
</div>
122+
</dialog>
123+
);
124+
};
125+
126+
export default InspectDialog;

‎src/gallery/PresetCard.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ interface PresetCardProps {
66
theme: MatrixTheme;
77
info: PresetInfo;
88
data: MatrixData;
9+
onInspect: () => void;
910
}
1011

1112
// A live, non-interactive preview: no `onCellClick`, so the gallery grid adds no
1213
// cell tab stops however many designs it shows.
13-
const PresetCard = ({ theme, info, data }: PresetCardProps) => {
14+
const PresetCard = ({ theme, info, data, onInspect }: PresetCardProps) => {
1415
const headingId = useId();
1516

1617
return (
@@ -23,6 +24,9 @@ const PresetCard = ({ theme, info, data }: PresetCardProps) => {
2324
))}
2425
</ul>
2526
<ReactMatrix data={data} theme={theme} style={{ '--rdm-font-size': '0.75rem' }} />
27+
<button type="button" className="preset-inspect" onClick={onInspect}>
28+
Inspect <span className="sr-only">{theme.name}</span>
29+
</button>
2630
</article>
2731
);
2832
};

0 commit comments

Comments
 (0)