Chart builder: SelectControl pickers, channel chooser, per-type aggregates

This commit is contained in:
2026-06-12 14:45:31 +03:00
parent 4dcff4601d
commit ed66fe9c05
15 changed files with 1232 additions and 276 deletions
+102 -51
View File
@@ -103,18 +103,6 @@
gap: var(--space-2);
}
.filterField {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.filterPredicate {
display: flex;
align-items: center;
@@ -173,7 +161,6 @@
font-family: var(--font-mono);
}
.filterField:focus-visible,
.calcName:focus-visible,
.valueInput:focus-visible,
.exprInput:focus-visible {
@@ -428,16 +415,6 @@
color: var(--text-placeholder);
}
.mini {
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
}
.toggle {
display: inline-flex;
align-items: center;
@@ -451,31 +428,11 @@
gap: var(--space-5);
}
.mini:focus-visible,
.dimInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.dimensions {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dimInputs {
display: flex;
gap: var(--space-3);
}
.dimField {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 12px;
color: var(--text-secondary);
}
.dimInput {
width: 100px;
padding: var(--space-2) var(--space-3);
@@ -713,6 +670,44 @@
color: var(--text-secondary);
}
/* Scroll viewport around the field lists: a wide dataset (Superstore: 25 columns)
must not push the Marks card and chart controls below the fold. Group headings
pin to the top while their list scrolls under them. */
.shelfScroll {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
overflow-y: auto;
}
/* An armed channel makes the shelf the live target — say so where the next click
happens (NN/g #1): an accent ring on the list plus the status line above it. */
.shelfArmed {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius);
}
.armedHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.armedHint strong {
color: var(--accent);
}
.shelfScroll .shelfGroupHead {
position: sticky;
top: 0;
z-index: 1;
margin-top: 0;
padding: var(--space-2) 0;
background: var(--layer-01);
}
.shelfList {
display: flex;
flex-direction: column;
@@ -785,11 +780,14 @@
gap: var(--space-2);
}
/* Pill — a bound channel (field/count or constant). */
/* Pill — a bound channel (field/count or constant). The per-channel transform
controls (Aggregate/Bin/Granularity) flow inline beside the pill, wrapping only
when the row truly runs out of room (a narrow window; the Marks card). */
.pillWrap {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-width: 0;
}
@@ -827,6 +825,10 @@
opacity: 0.85;
}
.pillType[aria-expanded='true'] {
background: color-mix(in srgb, #000 30%, var(--accent));
}
.pillType:focus-visible {
outline: 2px solid var(--accent-contrast);
outline-offset: -2px;
@@ -841,6 +843,8 @@
}
.pillName {
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
overflow: hidden;
@@ -937,19 +941,22 @@
outline-offset: 1px;
}
/* Ghost button (Carbon: links navigate, buttons act — this one acts). */
.slotConst {
padding: var(--space-1);
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius);
background: none;
color: var(--accent);
font: inherit;
font-size: 11px;
white-space: nowrap;
text-decoration: underline;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.slotConst:hover {
background: var(--layer-02);
color: var(--accent-hover);
}
@@ -966,10 +973,12 @@
padding: var(--space-5) var(--space-5) 0;
}
/* Swap sits beside the heading — an action flushed to the far edge of a very wide
pane reads as unrelated to the shelves it acts on. */
.shelvesHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
}
.shelfStrip {
@@ -1000,11 +1009,53 @@
padding: var(--space-2) var(--space-3);
}
.shelfSlots .slot,
.shelfSlots .pillWrap {
/* Empty slots keep a minimum presence; a filled pill hugs its content so the
remove ✕ sits at its right edge rather than adrift in dead pill background. */
.shelfSlots .slot {
min-width: 190px;
}
/* Chart properties — title/subtitle/size as a slim strip pinned under the preview
(chart-level properties live on the chart side; council 2026-06-12). */
.chartProps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-top: var(--border-width) solid var(--border);
}
.propField {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.propInput {
width: 200px;
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
}
.propInput:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.propInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
.facetSlot {
display: flex;
+93 -5
View File
@@ -194,7 +194,7 @@ describe('ChartBuilderModal', () => {
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
});
expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy();
const calls = vi.mocked(renderSpec).mock.calls;
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
@@ -297,7 +297,7 @@ describe('ChartBuilderModal', () => {
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
test('clicking a field in the shelf assigns it to a channel as a pill (field-first, 2B)', async () => {
test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
@@ -309,7 +309,6 @@ describe('ChartBuilderModal', () => {
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
// Clear the smart-default axes so the click lands on the first empty channel (X).
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
@@ -328,12 +327,100 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
// Unarmed, the click opens an explicit channel chooser (portaled to <body>)
// rather than silently filling the first empty seat (council 2026-06-12).
expect(useChartBuilderStore.getState().config.encodings.x).toBeNull();
const option = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Columns (X)'),
);
expect(option).toBeDefined();
await act(async () => {
option!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
field: 'region',
type: 'nominal',
});
});
test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
store.focusChannel('y');
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// The armed state announces itself at the shelf.
expect(container.textContent).toContain('Assigning to Y');
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('region'),
);
await act(async () => {
fieldButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.y).toEqual({
field: 'region',
type: 'nominal',
});
expect(useChartBuilderStore.getState().activeChannel).toBeNull();
});
test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => {
const ds = createDataset({
name: 'Sales',
data: [{ day: '2026-01-01', v: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity
store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)"
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label^="Granularity for"]',
);
expect(trigger).toBeTruthy();
await act(async () => {
trigger!.click();
await Promise.resolve();
});
// A selector list ('[aria-current="true"], button') would return the first
// button in document order — the "None (raw)" option — instead of the selection.
const focused = document.activeElement as HTMLElement;
expect(focused.getAttribute('aria-current')).toBe('true');
expect(focused.textContent).toContain('Month');
});
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
const ds = createDataset({
name: 'Shop',
@@ -351,9 +438,10 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
// The empty Colour slot offers an "or constant" affordance (Colour is first in Marks).
// The empty Colour slot offers a "Use a constant" ghost button (Colour is
// first in Marks).
const constButton = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'or constant',
(b) => b.textContent === 'Use a constant',
);
expect(constButton).toBeDefined();
+296 -174
View File
@@ -1,15 +1,19 @@
/**
* Chart Builder — the modal body (spec §06).
*
* A two-pane composer: left is the configuration (dataset name, mark selector, one
* block per channel, chart-level sort/stacking, optional dimensions, guidance,
* Create), right is a live preview of the spec the configuration produces. All spec
* logic and Tier-B defaults/guards come from `@core/chart-builder` via
* `ChartBuilderStore`; this component is the view. Each channel is a small block:
* a column dropdown (with a field-less "Count of records" option), a fixed
* `N | O | Q | T` field-type segmented control (the column's invalid types are
* disabled), and the transforms that apply to its type (aggregate + bin for a
* measure, granularity for a temporal field). The preview is builder-local (its own
* A two-pane composer. Left: the Data section (filters, calculated fields, row
* preview), the mark selector, the field shelf (columns as type-glyphed chips), the
* Marks card (Colour/Size — field or constant), chart-level sort/stacking, guidance,
* and Create. Right: the on-chart Columns/Rows shelves, the live preview, and the
* chart-properties strip (title/subtitle/size). All spec logic and defaults/guards
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the view.
*
* Assignment is field-first: a chip click opens an explicit channel chooser, or
* assigns directly when a channel is armed (the slot's "Pick a field…" state, made
* visible at the shelf). A bound channel renders as a pill — a field-type chip that
* opens a direct type pick, the field name, a remove ✕ — with its per-type
* transforms (aggregate / bin / granularity) inline beside it. Pickers use
* `SelectControl`, not native selects. The preview is builder-local (its own
* debounced render over the shared `chart-renderer` service) rather than a reuse of
* `LivePreview`, which is bound to the snippet editor's stores.
*/
@@ -29,12 +33,14 @@ import {
filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
isValueMapping,
supportsAggregate,
supportsBin,
supportsSort,
supportsStack,
supportsTimeUnit,
validAggregateOps,
validFieldTypes,
validFilterOps,
type BuilderCalculate,
@@ -65,6 +71,7 @@ import {
useChartBuilderStore,
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
import styles from './ChartBuilderModal.module.css';
@@ -124,8 +131,16 @@ const TYPE_ABBR: Record<FieldType, string> = {
temporal: 'T',
};
/** Non-count aggregate operators offered for a quantitative field. */
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];
/** Readable labels for the non-count aggregate operators (`validAggregateOps`
* supplies the per-type menu — e.g. a Nominal field offers only Count distinct). */
const AGGREGATE_LABELS: Record<Exclude<AggregateOp, 'count'>, string> = {
sum: 'Sum',
mean: 'Mean',
median: 'Median',
min: 'Min',
max: 'Max',
distinct: 'Count distinct',
};
/** Friendly labels for each temporal granularity. */
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
@@ -286,14 +301,9 @@ function ChannelPill({
const count = isCount(mapping);
const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
const canCycle = typeOptions.length > 1;
const canPick = typeOptions.length > 1;
const currentType: FieldType = count ? 'quantitative' : mapping.type;
const label = count ? 'Count' : (mapping.field ?? '');
const cycleType = () => {
if (!canCycle) return;
const i = typeOptions.indexOf(mapping.type);
setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]);
};
const hasTransforms =
!count &&
@@ -304,18 +314,20 @@ function ChannelPill({
return (
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
review (docs/ux-second-pass.md). */}
<button
type="button"
className={styles.pillType}
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
disabled={!canCycle}
onClick={cycleType}
>
{TYPE_ABBR[currentType]}
</button>
{/* The type chip opens a direct pick of the valid types (council 2026-06-12:
a cycling button gave keyboard/SR users no way to jump to a type). */}
<SelectControl
id={`cb-type-${channel}`}
label={`Field type for ${CHANNEL_LABELS[channel]}`}
heading="Field type"
options={typeOptions.map((t) => ({ value: t, label: titleCase(t) }))}
value={currentType}
onSelect={(t) => setChannelType(channel, t)}
triggerClassName={styles.pillType}
triggerContent={TYPE_ABBR[currentType]}
triggerTitle={titleCase(currentType)}
disabled={!canPick}
/>
<span className={styles.pillName} title={label}>
{label}
</span>
@@ -327,26 +339,25 @@ function ChannelPill({
{hasTransforms && (
<div className={styles.pillControls}>
{supportsAggregate(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
<select
className={styles.mini}
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Aggregate
</span>
<SelectControl
id={`cb-agg-${channel}`}
label={`Aggregate for ${CHANNEL_LABELS[channel]}`}
heading="Aggregate"
options={[
{ value: '', label: 'None' },
...validAggregateOps(mapping.type).map((op) => ({
value: op,
label: AGGREGATE_LABELS[op],
})),
]}
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
onChange={(e) =>
setChannelAggregate(
channel,
(e.target.value || undefined) as AggregateOp | undefined,
)
}
>
<option value="">None</option>
{FIELD_AGGREGATES.map((op) => (
<option key={op} value={op}>
{titleCase(op)}
</option>
))}
</select>
</label>
onSelect={(v) => setChannelAggregate(channel, v || undefined)}
/>
</div>
)}
{supportsBin(mapping.type) && (
@@ -361,23 +372,22 @@ function ChannelPill({
)}
{supportsTimeUnit(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Granularity</span>
<select
className={styles.mini}
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Granularity
</span>
<SelectControl
id={`cb-tu-${channel}`}
label={`Granularity for ${CHANNEL_LABELS[channel]}`}
heading="Granularity"
options={[
{ value: '', label: 'None (raw)' },
...TIME_UNITS.map((u) => ({ value: u, label: TIME_UNIT_LABELS[u] })),
]}
value={mapping.timeUnit ?? ''}
onChange={(e) =>
setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
}
>
<option value="">None (raw)</option>
{TIME_UNITS.map((u) => (
<option key={u} value={u}>
{TIME_UNIT_LABELS[u]}
</option>
))}
</select>
</label>
onSelect={(v) => setChannelTimeUnit(channel, v || undefined)}
/>
</div>
)}
</div>
)}
@@ -421,36 +431,75 @@ function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string })
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
</button>
{channelAcceptsValue(channel) && (
// A ghost button, not a link-styled affordance: it acts (binds a constant),
// and Carbon draws the line at links navigate / buttons act (council
// 2026-06-12). Verb-first label per the content rules.
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
or constant
Use a constant
</button>
)}
</div>
);
}
/** What a channel currently shows, for the assignment chooser's "replaces …" hint. */
function occupantLabel(mapping: ChannelMapping | null | undefined): string | undefined {
if (!mapping) return undefined;
if (mapping.value !== undefined) return 'replaces the constant';
if (mapping.aggregate === 'count' && !mapping.field) return 'replaces Count';
return mapping.field ? `replaces ${mapping.field}` : undefined;
}
/** Channel names as the assignment chooser shows them (the on-chart shelf words). */
const ASSIGN_LABELS: Record<ChannelName, string> = {
x: 'Columns (X)',
y: 'Rows (Y)',
color: 'Color',
size: 'Size',
};
/**
* The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
* calculated fields and a field-less "Count of records") as clickable chips with a type
* glyph. Clicking a field assigns it to the armed channel, else the first empty channel
* that accepts it (`assignField`). Past `FIELD_SHELF_SPLIT_MIN` columns it groups into
* Dimensions (categories/dates) and Measures (numerics); a small dataset stays flat.
* Already-mapped fields are dimmed (a field may still be placed on several channels).
* glyph. Clicking a chip opens an explicit channel chooser (the channels that accept the
* column; an occupied one says what it would replace); with a channel armed, the click
* assigns straight there instead and Esc disarms. Past `FIELD_SHELF_SPLIT_MIN` columns it
* groups into Dimensions (categories/dates) and Measures (numerics); a small dataset stays
* flat. Already-mapped fields are dimmed (a field may still be placed on several channels).
*/
function FieldShelf() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const encodings = useChartBuilderStore((s) => s.config.encodings);
const assignField = useChartBuilderStore((s) => s.assignField);
const activeChannel = useChartBuilderStore((s) => s.activeChannel);
const focusChannel = useChartBuilderStore((s) => s.focusChannel);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
// Esc disarms the armed channel (captured so the modal itself doesn't close).
// TODO: with a channel armed AND a SelectControl popover open, one Esc both closes
// the popover and disarms — both are capture-phase document listeners, so
// stopPropagation can't serialize them. Rare combination; needs a shared
// escape-layer stack if it ever matters.
useEffect(() => {
if (!activeChannel) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
focusChannel(null);
}
};
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [activeChannel, focusChannel]);
const assigned = useMemo(() => {
const set = new Set<string>();
for (const ch of CHANNELS) {
@@ -463,33 +512,59 @@ function FieldShelf() {
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const fieldButton = (name: string) => (
<button
key={name}
type="button"
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
onClick={() => assignField(name)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
</span>
<span className={styles.shelfFieldName}>{name}</span>
</button>
);
// A field chip: with a channel armed, clicking assigns straight there (the fast
// path); unarmed, it opens an explicit channel chooser instead of silently
// grabbing the first empty seat (council 2026-06-12 — NN/g #3, user choice).
const fieldChip = (
name: string,
display: string,
domId: string,
colType: ColumnType,
glyph: string,
) => {
const choices = CHANNELS.filter((ch) => isColumnAllowedOnChannel(ch, colType));
return (
<SelectControl
key={domId}
id={`cb-assign-${domId}`}
label={`Add ${display} to a channel`}
heading="Add to"
options={choices.map((ch) => ({
value: ch,
label: ASSIGN_LABELS[ch],
detail: occupantLabel(encodings[ch]),
}))}
onSelect={(ch) => assignField(name, ch)}
beforeOpen={() => {
if (activeChannel) {
assignField(name);
return false;
}
return true;
}}
triggerClassName={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
triggerContent={
<>
<span className={styles.shelfGlyph} aria-hidden="true">
{glyph}
</span>
<span className={styles.shelfFieldName}>{display}</span>
</>
}
/>
);
};
const countButton = (
<button
key="__count"
type="button"
className={styles.shelfField}
onClick={() => assignField(COUNT_FIELD)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
</span>
<span className={styles.shelfFieldName}>Count of records</span>
</button>
);
const fieldButton = (name: string) =>
fieldChip(
name,
name,
`f${columns.columns.indexOf(name)}`,
colTypeOf(name),
TYPE_ABBR[defaultFieldType(colTypeOf(name))],
);
const countButton = fieldChip(COUNT_FIELD, 'Count of records', 'count', 'number', '∑');
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
@@ -499,22 +574,33 @@ function FieldShelf() {
return (
<div className={styles.fieldShelf}>
<span className={styles.fieldLabel}>Fields</span>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
{/* Arming a channel must be visible at the place the next click happens
(NN/g #1): the shelf gains an accent ring and a status line naming the
target. The hint is a polite status so AT hears the mode change too. */}
{activeChannel && (
<p className={styles.armedHint} role="status">
Assigning to <strong>{CHANNEL_LABELS[activeChannel]}</strong> choose a field below. Esc
cancels.
</p>
)}
<div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{columns.columns.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{columns.columns.map(fieldButton)}
{countButton}
</div>
)}
)}
</div>
</div>
);
}
@@ -687,19 +773,15 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/>
) : (
<select
className={styles.filterField}
aria-label="Filter field"
value={filter.field ?? ''}
onChange={(e) => setFilterField(filter.id, e.target.value)}
>
{!filter.field && <option value="">Choose a field</option>}
{columns.columns.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<SelectControl
id={`cb-ffield-${filter.id}`}
label="Filter field"
heading="Filter field"
options={columns.columns.map((name) => ({ value: name, label: name }))}
value={filter.field}
onSelect={(name) => setFilterField(filter.id, name)}
triggerContent={filter.field ? undefined : 'Choose a field…'}
/>
)}
<button
type="button"
@@ -713,18 +795,17 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
{!expressionMode && (
<div className={styles.filterPredicate}>
<select
className={styles.mini}
aria-label="Filter operator"
<SelectControl
id={`cb-fop-${filter.id}`}
label="Filter operator"
heading="Operator"
options={validFilterOps(fieldType).map((o) => ({
value: o,
label: FILTER_OP_LABELS[o],
}))}
value={op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
>
{validFilterOps(fieldType).map((o) => (
<option key={o} value={o}>
{FILTER_OP_LABELS[o]}
</option>
))}
</select>
onSelect={(o) => updateFilter(filter.id, { op: o })}
/>
{arity === 'range' ? (
<>
<input
@@ -985,6 +1066,11 @@ function BuilderPreview() {
const specText = useChartBuilderStore(selectBuilderSpecText);
const valid = useChartBuilderStore(selectBuilderValid);
// An explicit Chart size must show up in the preview — the 'width' fit mode
// overwrites width AND drops height, so it only applies while sizing is auto.
const explicitSize = useChartBuilderStore(
(s) => s.config.width !== undefined || s.config.height !== undefined,
);
const uiTheme = useAppStore((s) => s.uiTheme);
const datasets = useDatasetStore(useShallow((s) => s.datasets));
@@ -1005,7 +1091,10 @@ function BuilderPreview() {
const t0 = performance.now();
const parsed: unknown = JSON.parse(specText);
const t1 = performance.now();
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
const prepared = prepareSpecForRender(parsed, {
fitMode: explicitSize ? 'default' : 'width',
datasets,
});
const t2 = performance.now();
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
handleRef.current = null;
@@ -1069,7 +1158,7 @@ function BuilderPreview() {
}, RENDER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [specText, valid, uiTheme, datasets]);
}, [specText, valid, explicitSize, uiTheme, datasets]);
useEffect(
() => () => {
@@ -1110,19 +1199,87 @@ function BuilderPreview() {
);
}
/** A positive integer from a dimension input; blank/garbage reads as "auto". */
function parseDim(raw: string): number | undefined {
if (raw.trim() === '') return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
}
/**
* Chart properties — title/subtitle and explicit width/height, as a slim strip
* pinned under the preview: these describe *the chart*, so they live on the chart
* side, not in the encoding pane (council 2026-06-12 — NN/g #4, the Tableau/Lyra
* convention). The subtitle is disabled until a title exists because Vega-Lite has
* no standalone subtitle (it nests under `title`).
*/
function ChartProps() {
const title = useChartBuilderStore((s) => s.config.title ?? '');
const subtitle = useChartBuilderStore((s) => s.config.subtitle ?? '');
const width = useChartBuilderStore((s) => s.config.width);
const height = useChartBuilderStore((s) => s.config.height);
const setTitle = useChartBuilderStore((s) => s.setTitle);
const setSubtitle = useChartBuilderStore((s) => s.setSubtitle);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const hasTitle = title.trim() !== '';
return (
<div className={styles.chartProps}>
<label className={styles.propField}>
<span>Title</span>
<input
className={styles.propInput}
value={title}
placeholder="None"
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Subtitle</span>
<input
className={styles.propInput}
value={subtitle}
placeholder={hasTitle ? 'None' : 'Add a title first'}
disabled={!hasTitle}
onChange={(e) => setSubtitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Width</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={width ?? ''}
placeholder="auto"
onChange={(e) => setWidth(parseDim(e.target.value))}
/>
</label>
<label className={styles.propField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</div>
);
}
export function ChartBuilderModal() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
const mark = useChartBuilderStore((s) => s.config.mark);
const width = useChartBuilderStore((s) => s.config.width);
const height = useChartBuilderStore((s) => s.config.height);
const sort = useChartBuilderStore((s) => s.config.sort);
const stack = useChartBuilderStore((s) => s.config.stack);
const setMark = useChartBuilderStore((s) => s.setMark);
const setSort = useChartBuilderStore((s) => s.setSort);
const setStack = useChartBuilderStore((s) => s.setStack);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
const runCreate = useChartBuilderStore((s) => s.createSnippet);
@@ -1170,12 +1327,6 @@ export function ChartBuilderModal() {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
}
const parseDim = (raw: string): number | undefined => {
if (raw.trim() === '') return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
};
return (
<div className={styles.builder}>
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
@@ -1228,36 +1379,6 @@ export function ChartBuilderModal() {
</div>
)}
<div className={styles.dimensions}>
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
channel; this is the rendered chart's width/height. */}
<span className={styles.fieldLabel}>Chart size (optional)</span>
<div className={styles.dimInputs}>
<label className={styles.dimField}>
<span>Width</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={width ?? ''}
placeholder="auto"
onChange={(e) => setWidth(parseDim(e.target.value))}
/>
</label>
<label className={styles.dimField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</div>
</div>
{warnings.length > 0 && (
<ul
className={styles.warnings}
@@ -1314,6 +1435,7 @@ export function ChartBuilderModal() {
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
<ChartProps />
</div>
</div>
);
+123
View File
@@ -0,0 +1,123 @@
/* SelectControl — the app's value-picker disclosure (replaces native <select>;
arch 10 §5). Trigger + panel mirror SortControl's geometry and tokens. */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.trigger:hover:not(:disabled) {
background: var(--layer-01);
}
.trigger[aria-expanded='true'] {
background: var(--layer-02);
}
.trigger:disabled {
cursor: default;
opacity: 0.6;
}
.trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.caret {
font-size: 9px;
color: var(--text-secondary);
}
/* The disclosed panel — portaled to <body>, fixed, above modal content. */
.pop {
position: fixed;
z-index: 2000;
min-width: 150px;
max-width: 320px;
padding: var(--space-2);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.4);
}
.heading {
margin: 0 0 var(--space-2);
padding: 0 var(--space-2);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* A long option list (a 25-column filter-field picker) scrolls in place. */
.list {
display: flex;
flex-direction: column;
max-height: min(320px, 50vh);
overflow-y: auto;
}
.option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.option:hover {
background: var(--layer-02);
}
.option:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.selected {
font-weight: 600;
}
.optionLabel {
display: flex;
flex-direction: column;
min-width: 0;
}
.detail {
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* SelectControl — the app's value picker: a disclosure trigger showing the current
* choice plus a portaled, non-modal popover listing the options as plain buttons.
*
* This replaces native `<select>` elements wherever a control is part of a designed
* surface: a native select's popup can't be token-styled and renders differently on
* every browser/OS, which reads as a foreign object inside an otherwise consistent
* UI (arch 10 §5 records the resolution). It is the same APG **disclosure**
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
* a combobox; a short list of buttons needs neither's contract.
*
* Behaviour (mirrors SortControl): at most one popover is open app-wide
* (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside
* pointer press closes; opening focuses the selected option (or the first);
* Arrow/Home/End move focus through the options; choosing one fires `onSelect`,
* closes, and refocuses the trigger. The panel is portaled to <body> and positioned
* fixed so it escapes pane/modal overflow clipping, flipping above the trigger when
* the viewport below is too short.
*
* The same control doubles as an **action picker** (e.g. "Add field to which
* channel?"): pass no `value` and a custom `triggerContent`; `beforeOpen` lets the
* caller intercept the click entirely (the armed-channel fast path).
*/
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import styles from './SelectControl.module.css';
/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */
const GAP = 6;
export interface SelectControlOption<V extends string> {
value: V;
label: string;
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
detail?: string;
}
export interface SelectControlProps<V extends string> {
/** Unique id — popover registry key and the panel's DOM id. */
id: string;
/** Accessible name for the control ("Aggregate for Y", "Add Region to…"). */
label: string;
options: ReadonlyArray<SelectControlOption<V>>;
/** Current value; omit for an action picker (no option reads as selected). */
value?: V;
onSelect: (value: V) => void;
/** Trigger body; defaults to the current option's label plus a caret. */
triggerContent?: ReactNode;
/** Replaces (not extends) the default trigger styling — for chip-styled triggers. */
triggerClassName?: string;
triggerTitle?: string;
disabled?: boolean;
/** Popover heading; defaults to `label`. */
heading?: string;
/** Return false to swallow the trigger click without opening (fast paths). */
beforeOpen?: () => boolean;
}
export function SelectControl<V extends string>({
id,
label,
options,
value,
onSelect,
triggerContent,
triggerClassName,
triggerTitle,
disabled,
heading,
beforeOpen,
}: SelectControlProps<V>) {
const open = useSettingsPopoverStore((s) => s.openId === id);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
// Fixed-position from the trigger's rect (no React state → no re-render on
// scroll). Below the trigger by default; above when the viewport below is short.
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
const below = window.innerHeight - r.bottom - GAP;
const height = pop.offsetHeight;
pop.style.top =
below < height && r.top > height + GAP ? `${r.top - GAP - height}px` : `${r.bottom + GAP}px`;
// Keep the panel on-screen when the trigger sits near the right edge.
const left = Math.min(r.left, window.innerWidth - pop.offsetWidth - GAP);
pop.style.left = `${Math.max(GAP, left)}px`;
}, []);
useEffect(() => {
if (!open) return;
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [open, place]);
// Esc closes + restores focus to the trigger; an outside pointer press closes
// (APG disclosure; non-modal). Esc is captured so it settles here, not on a
// parent (the builder modal also listens for Esc).
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
triggerRef.current?.focus();
}
};
const onPointer = (e: PointerEvent) => {
const t = e.target as Node;
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('keydown', onKey, true);
document.addEventListener('pointerdown', onPointer, true);
return () => {
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('pointerdown', onPointer, true);
};
}, [open, close]);
// Arrow/Home/End roving among the option buttons — a convenience on top of the
// natural Tab order, matching what a native select's popup offers.
const onPopKeyDown = (e: React.KeyboardEvent) => {
const pop = popRef.current;
if (!pop) return;
// Tab closes the popup and resumes tabbing from the trigger (the native-select
// convention) — also keeps focus inside a host modal's trap, since the panel is
// portaled outside it.
if (e.key === 'Tab') {
close();
triggerRef.current?.focus();
return;
}
const items = Array.from(pop.querySelectorAll<HTMLButtonElement>('button'));
const i = items.indexOf(document.activeElement as HTMLButtonElement);
let next = -1;
if (e.key === 'ArrowDown') next = i < 0 ? 0 : Math.min(i + 1, items.length - 1);
else if (e.key === 'ArrowUp') next = i < 0 ? items.length - 1 : Math.max(i - 1, 0);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = items.length - 1;
if (next >= 0) {
e.preventDefault();
items[next]?.focus();
}
};
// On mount: position before paint, then land focus on the selected option (or
// the first) so keyboard users arrive inside the popover.
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
// Two queries, not one selector list — `querySelector('a, b')` returns the
// first match in document order, which would always be the first button.
const target =
node.querySelector<HTMLElement>('[aria-current="true"]') ??
node.querySelector<HTMLElement>('button');
target?.focus();
}
},
[place],
);
const choose = (v: V) => {
onSelect(v);
close();
triggerRef.current?.focus();
};
return (
<>
<button
ref={triggerRef}
type="button"
className={triggerClassName ?? styles.trigger}
aria-expanded={open}
aria-controls={open ? id : undefined}
aria-label={current ? `${label}: ${current.label}` : label}
title={triggerTitle}
disabled={disabled}
onClick={() => {
if (beforeOpen && !beforeOpen()) return;
toggle(id);
}}
>
{triggerContent ?? (
<>
<span className={styles.triggerLabel}>{current?.label ?? '—'}</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
)}
</button>
{open &&
createPortal(
<div
ref={setPopNode}
id={id}
className={styles.pop}
role="group"
aria-label={label}
onKeyDown={onPopKeyDown}
>
<h4 className={styles.heading}>{heading ?? label}</h4>
<div className={styles.list}>
{options.map((o) => {
const selected = value !== undefined && o.value === value;
return (
<button
key={o.value}
type="button"
className={`${styles.option} ${selected ? styles.selected : ''}`}
aria-current={selected || undefined}
onClick={() => choose(o.value)}
>
<span className={styles.optionLabel}>
{o.label}
{o.detail !== undefined && <span className={styles.detail}>{o.detail}</span>}
</span>
{selected && <span aria-hidden="true"></span>}
</button>
);
})}
</div>
</div>,
document.body,
)}
</>
);
}