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
@@ -58,6 +58,79 @@
gap: var(--space-3);
}
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with
the primary Create action (--accent stays reserved for primary actions). */
.intentStrip {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: var(--border-width) solid var(--border);
background: var(--accent-soft);
}
.intentQ {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.intentSub {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.intentChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.intentChip {
appearance: none;
height: var(--control-height);
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
padding: 0 var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.intentChip:hover {
background: var(--field);
}
/* The chip whose layout the live chart matches (council-reserved: selection uses an
accent ring, never an accent fill — fill stays the primary-action signal). */
.intentChip[aria-pressed='true'] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
accessible name carries the reason). */
.intentChip[aria-disabled='true'] {
color: var(--text-placeholder);
border-color: var(--border);
background: var(--bg);
cursor: not-allowed;
}
.intentChip:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection {
@@ -346,6 +419,18 @@
gap: var(--space-2);
}
/* The mark picker carries six segments — too many for one 320360px row. Override the
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
height so the two rows read as even segments inside the shared border. */
.markPicker {
flex-wrap: wrap;
height: auto;
}
.markPickerOption {
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
@@ -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);
});
});
});
+152 -1
View File
@@ -23,14 +23,17 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
CHART_INTENTS,
MARK_TYPES,
TIME_UNITS,
activeIntent,
builderWarnings,
channelAcceptsValue,
defaultChannelValue,
defaultFieldType,
effectiveColumns,
filterOpArity,
intentApplicable,
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
@@ -50,6 +53,7 @@ import {
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type ChartIntent,
type FieldType,
type FilterOp,
type MarkType,
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
* is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON
* audience the builder serves (the spec's mark-set table notes the rect↔heatmap pairing). */
const MARK_LABELS: Record<MarkType, string> = {
bar: 'Bar',
line: 'Line',
point: 'Point',
area: 'Area',
circle: 'Circle',
rect: 'Heatmap',
};
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
value: m,
label: titleCase(m),
label: MARK_LABELS[m],
title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
}));
/** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the
* dataset must have for the intent to apply (shown when the chip is disabled). */
const INTENT_LABELS: Record<ChartIntent, string> = {
compare: 'Compare',
ranking: 'Ranking',
time: 'Change over time',
correlation: 'Correlation',
distribution: 'Distribution',
partToWhole: 'Part-to-whole',
heatmap: 'Heatmap',
};
const INTENT_NEEDS: Record<ChartIntent, string> = {
compare: 'a category column',
ranking: 'a category column',
time: 'a date column',
correlation: 'two number columns',
distribution: 'a number column',
partToWhole: 'two category columns',
heatmap: 'two category columns',
};
const CHANNEL_LABELS: Record<ChannelName, string> = {
x: 'X',
y: 'Y',
@@ -1277,6 +1316,112 @@ function DatasetPicker({ datasetId }: { datasetId: number | null }) {
);
}
/**
* The intent-first front door (spec §06 → Intent): a persistent "what do you want to
* show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
* channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't
* satisfy are disabled (with the reason in their name), and the chip whose layout the
* live chart matches stays highlighted; once the user edits away from any, none is — a
* "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
* drive the channels directly). The active intent is derived from the config, so no
* intent state is stored.
*/
function IntentStrip() {
const columns = useChartBuilderStore((s) => s.columns);
const config = useChartBuilderStore((s) => s.config);
const setIntent = useChartBuilderStore((s) => s.setIntent);
const active = useMemo(() => activeIntent(config, columns), [config, columns]);
const applicable = useMemo(
() => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
[columns],
);
// APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
// NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
// focus only — Enter/Space activates — because applying an
// intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
// every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
// discoverable (APG: focusable disabled controls where discoverability is crucial).
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const tabStop = useMemo(() => {
if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
return firstEnabled >= 0 ? firstEnabled : 0;
}, [active, applicable]);
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
const n = CHART_INTENTS.length;
let next: number;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
next = (index + 1) % n;
break;
case 'ArrowLeft':
case 'ArrowUp':
next = (index - 1 + n) % n;
break;
case 'Home':
next = 0;
break;
case 'End':
next = n - 1;
break;
default:
return; // not ours — let it bubble (Enter/Space activate the native button)
}
e.preventDefault();
btnRefs.current[next]?.focus();
};
return (
<div className={styles.intentStrip}>
<span className={styles.intentQ} id="cb-intent-q">
What do you want to show?
</span>
<p className={styles.intentSub}>
{active ? (
<>
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
controls below.
</>
) : (
<>Pick a starting point and we will set the chart up or build it yourself below.</>
)}
</p>
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
{CHART_INTENTS.map((intent, i) => {
const enabled = applicable.has(intent);
return (
<button
key={intent}
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
className={styles.intentChip}
aria-pressed={active === intent}
aria-disabled={!enabled || undefined}
aria-label={
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
}
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
tabIndex={i === tabStop ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
onClick={() => {
if (enabled) setIntent(intent);
}}
>
{INTENT_LABELS[intent]}
</button>
);
})}
</div>
</div>
);
}
/**
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
* what the builder does and offers the one next step — never a dead end. The
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
</div>
<DatasetPicker datasetId={datasetId} />
<IntentStrip />
<DataSection />
<div className={styles.field}>
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
options={MARK_OPTIONS}
value={mark}
onChange={setMark}
// Six marks don't fit one 320360px row: wrap to two, each segment kept at
// the control height (the base look is fixed-height, single-row).
className={styles.markPicker}
optionClassName={styles.markPickerOption}
/>
</div>
+17
View File
@@ -45,6 +45,23 @@ describe('init', () => {
});
});
describe('setIntent (front door "do it for me")', () => {
test('reshapes the chart to the intent layout and disarms any armed channel', () => {
const id = seedDataset('Sales', [
{ region: 'E', segment: 'A', sales: 5 },
{ region: 'W', segment: 'B', sales: 9 },
]);
cb().init(id);
cb().focusChannel('color'); // arm a channel first
cb().setIntent('heatmap');
expect(cb().config.mark).toBe('rect');
expect(cb().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(cb().config.encodings.x?.field).toBeDefined();
expect(cb().config.encodings.y?.field).toBeDefined();
expect(cb().activeChannel).toBeNull();
});
});
describe('channel editing', () => {
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
+13
View File
@@ -18,6 +18,7 @@
import { create } from 'zustand';
import {
CHANNELS,
applyIntent,
buildSnippetSpecText,
channelAcceptsValue,
coerceChannelValue,
@@ -40,6 +41,7 @@ import {
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChartIntent,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
@@ -111,6 +113,14 @@ export interface ChartBuilderState {
* `rebaseBuilderConfig`).
*/
switchDataset: (datasetId: number) => void;
/**
* Reshape the chart to an intent's recommended layout — the front door's "do it
* for me" (spec §06 → Intent). Replaces mark + encodings + sort/stack with the
* intent's; keeps the dataset, data transforms, and chart properties. The active
* intent is derived from the config (`activeIntent`), not stored, so an edit after
* a pick reads as "Custom" with no extra state.
*/
setIntent: (intent: ChartIntent) => void;
setMark: (mark: MarkType) => void;
/**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
@@ -310,6 +320,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
});
},
setIntent: (intent) =>
set((s) => ({ activeChannel: null, config: applyIntent(s.config, intent, s.columns) })),
setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
setChannelColumn: (channel, columnName) =>