mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: SelectControl pickers, channel chooser, per-type aggregates
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,38 @@ describe('transforms — aggregate / bin / timeUnit / count', () => {
|
||||
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
|
||||
});
|
||||
|
||||
test('assignField with an explicit channel places the field there, replacing any occupant', () => {
|
||||
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('x', 'revenue');
|
||||
cb().assignField('region', 'x'); // explicit target from the chip's channel chooser
|
||||
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
|
||||
expect(cb().activeChannel).toBeNull();
|
||||
});
|
||||
|
||||
test('setTitle / setSubtitle set and clear the chart title fields', () => {
|
||||
const id = seedDataset('S', [{ v: 1 }]);
|
||||
cb().init(id);
|
||||
cb().setTitle('My chart');
|
||||
cb().setSubtitle('A closer look');
|
||||
expect(cb().config.title).toBe('My chart');
|
||||
expect(cb().config.subtitle).toBe('A closer look');
|
||||
cb().setTitle('');
|
||||
expect(cb().config.title).toBeUndefined();
|
||||
});
|
||||
|
||||
test('retyping drops a now-invalid aggregate but keeps a distinct (valid for any type)', () => {
|
||||
const id = seedDataset('S', [{ price: 5 }]);
|
||||
cb().init(id);
|
||||
cb().setChannelColumn('x', 'price');
|
||||
cb().setChannelAggregate('x', 'sum');
|
||||
cb().setChannelType('x', 'nominal'); // sum can't reduce a nominal field
|
||||
expect(cb().config.encodings.x?.aggregate).toBeUndefined();
|
||||
cb().setChannelAggregate('x', 'distinct');
|
||||
cb().setChannelType('x', 'ordinal'); // distinct counts unique values of anything
|
||||
expect(cb().config.encodings.x?.aggregate).toBe('distinct');
|
||||
});
|
||||
|
||||
test('setChannelTimeUnit sets and clears granularity', () => {
|
||||
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
|
||||
cb().init(id);
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
isChannelTypeAllowed,
|
||||
isColumnAllowedOnChannel,
|
||||
pruneEncodings,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
supportsTimeUnit,
|
||||
validAggregateOps,
|
||||
validFieldTypes,
|
||||
validFilterOps,
|
||||
type AggregateOp,
|
||||
@@ -106,9 +106,11 @@ export interface ChartBuilderState {
|
||||
/**
|
||||
* Place a clicked shelf field on a channel: the armed channel if it accepts the
|
||||
* column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
|
||||
* channel can take it. Clears the armed slot.
|
||||
* channel can take it. Clears the armed slot. An explicit `channel` (the field
|
||||
* chip's channel chooser) overrides both — it places the column there even when
|
||||
* the channel is occupied (the chooser labels that as a replacement).
|
||||
*/
|
||||
assignField: (columnName: string) => void;
|
||||
assignField: (columnName: string, channel?: ChannelName) => void;
|
||||
/**
|
||||
* Set/replace a channel's binding with a **constant value** (the Property model,
|
||||
* spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
|
||||
@@ -151,6 +153,10 @@ export interface ChartBuilderState {
|
||||
removeCalculate: (id: string) => void;
|
||||
setWidth: (width: number | undefined) => void;
|
||||
setHeight: (height: number | undefined) => void;
|
||||
/** Set/clear the chart title (whitespace-only clears). */
|
||||
setTitle: (raw: string) => void;
|
||||
/** Set/clear the subtitle; emitted into the spec only alongside a title. */
|
||||
setSubtitle: (raw: string) => void;
|
||||
/** Build the spec, create + activate a linked snippet, toast, and close. */
|
||||
createSnippet: (now?: Date) => boolean;
|
||||
reset: () => void;
|
||||
@@ -259,9 +265,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
|
||||
focusChannel: (channel) => set({ activeChannel: channel }),
|
||||
|
||||
assignField: (columnName) =>
|
||||
assignField: (columnName, channel) =>
|
||||
set((s) => {
|
||||
const target = chooseTargetChannel(s, columnName);
|
||||
const target = channel ?? chooseTargetChannel(s, columnName);
|
||||
if (!target) return { activeChannel: null }; // nothing can take it → just disarm
|
||||
const mapping = mappingForColumn(s, target, columnName);
|
||||
return {
|
||||
@@ -288,10 +294,18 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
set((s) => {
|
||||
const current = s.config.encodings[channel];
|
||||
if (!current) return s; // no field on this channel → nothing to retype
|
||||
// Drop transforms that no longer apply to the new type (e.g. an aggregate or
|
||||
// bin when leaving Quantitative, a granularity when leaving Temporal).
|
||||
// Drop transforms that no longer apply to the new type (e.g. a sum or bin when
|
||||
// leaving Quantitative, a granularity when leaving Temporal). A `distinct`
|
||||
// survives any retype (it applies to every field type); `count` is field-less
|
||||
// and never reaches here (a count pill offers no type cycle).
|
||||
const next: ChannelMapping = { ...current, type };
|
||||
if (!supportsAggregate(type)) delete next.aggregate;
|
||||
if (
|
||||
next.aggregate &&
|
||||
next.aggregate !== 'count' &&
|
||||
!validAggregateOps(type).includes(next.aggregate)
|
||||
) {
|
||||
delete next.aggregate;
|
||||
}
|
||||
if (!supportsBin(type)) delete next.bin;
|
||||
if (!supportsTimeUnit(type)) delete next.timeUnit;
|
||||
return updateEncoding(s, channel, next);
|
||||
@@ -451,6 +465,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
setWidth: (width) => set((s) => ({ config: { ...s.config, width } })),
|
||||
setHeight: (height) => set((s) => ({ config: { ...s.config, height } })),
|
||||
|
||||
// The raw input value is kept (mid-edit spaces and all) so typing feels normal;
|
||||
// the assembler trims and skips a blank/whitespace-only title at emission.
|
||||
setTitle: (raw) => set((s) => ({ config: { ...s.config, title: raw || undefined } })),
|
||||
setSubtitle: (raw) => set((s) => ({ config: { ...s.config, subtitle: raw || undefined } })),
|
||||
|
||||
createSnippet: (now) => {
|
||||
const { config } = get();
|
||||
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
|
||||
|
||||
Reference in New Issue
Block a user