Chart builder: intent front door, Heatmap mark, role-aware guidance

This commit is contained in:
2026-06-13 12:08:11 +03:00
parent 4e5108f434
commit 0470389b41
11 changed files with 1129 additions and 37 deletions
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
// A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull();
});
// The intent front door's core logic (which chip lights, what each applies, gating)
// is covered in core/store; these check only the parts that live in the component —
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
// shared primitive), and that a click reshapes the chart.
describe('intent front door (the "What do you want to show?" strip)', () => {
// Two categories + one date + two measures → every intent applies; enough shape to
// light a default and to exercise an applied intent (Heatmap needs two categories).
const seedSuperstore = () => {
const ds = createDataset({
name: 'Superstore',
data: [
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
return id;
};
const chips = (): HTMLButtonElement[] =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
// Exactly one chip is in the tab order; the rest are roving (-1).
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
// The smart default for a category+count shape is Compare, so its chip is pressed.
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
expect(pressed).toHaveLength(1);
expect(pressed[0].textContent).toBe('Compare');
});
test('an inapplicable intent is disabled and names its reason', async () => {
// One category, one measure, no date and no second measure → Correlation/Time/
// Heatmap/Part-to-whole cannot apply.
const ds = createDataset({
name: 'Thin',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
expect(correlation.getAttribute('aria-disabled')).toBe('true');
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
});
test('clicking an enabled chip reshapes the chart to that intent', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
await act(async () => {
heatmap.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
type: 'quantitative',
aggregate: 'count',
});
});
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
const start = all.findIndex((b) => b.tabIndex === 0);
const markBefore = useChartBuilderStore.getState().config.mark;
await act(async () => {
all[start].focus();
all[start].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await Promise.resolve();
});
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
});
});
});