Chart builder: equal shelf heights, pill floor, flat field rows, scroll affordance

This commit is contained in:
2026-06-12 23:13:26 +03:00
parent 7d4906ec38
commit b8f0b97f22
2 changed files with 87 additions and 108 deletions
+63 -84
View File
@@ -131,7 +131,8 @@
.valueInput { .valueInput {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: var(--space-1) var(--space-2); height: var(--control-height);
padding: 0 var(--space-2);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
@@ -149,7 +150,8 @@
.exprInput { .exprInput {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: var(--space-2) var(--space-3); height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
@@ -161,7 +163,8 @@
.calcName { .calcName {
flex: 0 1 110px; flex: 0 1 110px;
min-width: 0; min-width: 0;
padding: var(--space-2) var(--space-3); height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
@@ -187,30 +190,7 @@
border-color: var(--support-error); border-color: var(--support-error);
} }
/* Remove-row × — icon-only, quiet until hovered. */ /* Remove-row × is the shared IconButton (sm). */
.removeRow {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-placeholder);
cursor: pointer;
border-radius: var(--radius);
}
.removeRow:hover {
background: var(--layer-01);
color: var(--text);
}
.removeRow:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */ /* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */
.modeToggle { .modeToggle {
@@ -450,7 +430,8 @@
.dimInput { .dimInput {
width: 100px; width: 100px;
padding: var(--space-2) var(--space-3); height: var(--control-height);
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
@@ -540,6 +521,7 @@
text-align: right; text-align: right;
} }
/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -547,45 +529,6 @@
margin-top: auto; margin-top: auto;
} }
.action {
height: 36px;
padding: 0 var(--space-5);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover {
background: var(--layer-01);
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.primary {
background: var(--accent);
border-color: transparent;
color: var(--accent-contrast);
font-weight: 600;
}
.primary:hover {
background: var(--accent-hover);
}
.primary:disabled {
background: var(--layer-02, var(--layer-01));
color: var(--text-placeholder);
cursor: not-allowed;
}
/* ── Right: live preview ─────────────────────────────────────────────── */ /* ── Right: live preview ─────────────────────────────────────────────── */
/* Right side = on-chart shelves stacked above the live preview (spec §06). */ /* Right side = on-chart shelves stacked above the live preview (spec §06). */
@@ -694,6 +637,22 @@
gap: var(--space-2); gap: var(--space-2);
max-height: 280px; max-height: 280px;
overflow-y: auto; overflow-y: auto;
/* Scroll shadows (background-attachment trick): a soft cue at the cut edge that
appears only while there is more list in that direction. The covers (first
two layers, attachment: local) ride with the content and hide the shadows at
the ends; rows are transparent so the shadows show through between them. */
background:
linear-gradient(var(--bg) 30%, transparent),
linear-gradient(transparent, var(--bg) 70%) 0 100%,
linear-gradient(rgb(0 0 0 / 0.1), transparent),
linear-gradient(transparent, rgb(0 0 0 / 0.1)) 0 100%;
background-repeat: no-repeat;
background-size:
100% 24px,
100% 24px,
100% 8px,
100% 8px;
background-attachment: local, local, scroll, scroll;
} }
/* An armed channel makes the shelf the live target — say so where the next click /* An armed channel makes the shelf the live target — say so where the next click
@@ -726,33 +685,39 @@
.shelfList { .shelfList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
} }
/* Field rows are FLAT — hairline dividers and a hover fill, not per-row boxes:
25 outlined rectangles read as noise, and flat rows are denser, so more of a
wide dataset is visible before the shelf scrolls (arch 09 §4 — boxes are for
inputs and drop targets, not list rows). */
.shelfField { .shelfField {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
width: 100%; width: 100%;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-2);
border: var(--border-width) solid var(--border); border: none;
border-radius: var(--radius); background: transparent;
background: var(--bg);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.shelfField + .shelfField {
border-top: var(--border-width) solid var(--border);
} }
.shelfField:hover { .shelfField:hover {
border-color: var(--accent);
background: var(--layer-01); background: var(--layer-01);
} }
.shelfField:focus-visible { .shelfField:focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--focus);
outline-offset: 1px; outline-offset: -1px;
} }
.shelfFieldUsed { .shelfFieldUsed {
@@ -808,7 +773,12 @@
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: stretch;
/* One pill height, and a floor on width so "Count" and "Customer Name" read as
the same species; names stay content-sized above the floor (truncating to
equal widths would cost more than it buys). */
height: 24px;
min-width: 96px;
max-width: 100%; max-width: 100%;
background: var(--accent); background: var(--accent);
color: var(--accent-contrast); color: var(--accent-contrast);
@@ -821,7 +791,9 @@
} }
.pillType { .pillType {
padding: var(--space-2); display: inline-flex;
align-items: center;
padding: 0 var(--space-2);
border: none; border: none;
background: color-mix(in srgb, #000 18%, var(--accent)); background: color-mix(in srgb, #000 18%, var(--accent));
color: var(--accent-contrast); color: var(--accent-contrast);
@@ -850,7 +822,9 @@
} }
.pillTag { .pillTag {
padding: var(--space-2); display: inline-flex;
align-items: center;
padding: 0 var(--space-2);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
@@ -861,7 +835,8 @@
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */ /* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: var(--space-2) var(--space-3); align-self: center;
padding: 0 var(--space-3);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -872,7 +847,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--space-2); padding: 0 var(--space-2);
border: none; border: none;
background: transparent; background: transparent;
color: inherit; color: inherit;
@@ -922,7 +897,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-height: 32px; min-height: var(--control-height);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
border: var(--border-width) dashed var(--border-strong); border: var(--border-width) dashed var(--border-strong);
background: var(--layer-01); background: var(--layer-01);
@@ -999,7 +974,10 @@
.shelfStrip { .shelfStrip {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
min-height: 42px; /* One fixed minimum for BOTH strips — sized to the tallest occupant (a 32px
control + the slot padding), so Columns (which carries the Aggregate select)
and Rows stay the same height instead of each hugging its own content. */
min-height: 48px;
border: var(--border-width) solid var(--border); border: var(--border-width) solid var(--border);
} }
@@ -1052,13 +1030,14 @@
.propInput { .propInput {
width: 200px; width: 200px;
padding: var(--space-1) var(--space-2); height: var(--control-height);
padding: 0 var(--space-2);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 12px; font-size: 13px;
} }
.propInput:disabled { .propInput:disabled {
@@ -1077,7 +1056,7 @@
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
min-width: 150px; min-width: 150px;
min-height: 32px; min-height: var(--control-height);
padding: var(--space-1) var(--space-3); padding: var(--space-1) var(--space-3);
border: var(--border-width) dashed var(--border); border: var(--border-width) dashed var(--border);
color: var(--text-placeholder); color: var(--text-placeholder);
+24 -24
View File
@@ -71,6 +71,8 @@ import {
useChartBuilderStore, useChartBuilderStore,
} from '../stores/ChartBuilderStore'; } from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl'; import { SelectControl } from './SelectControl';
import { Icon } from './Icon'; import { Icon } from './Icon';
import styles from './ChartBuilderModal.module.css'; import styles from './ChartBuilderModal.module.css';
@@ -579,9 +581,11 @@ function FieldShelf() {
<div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}> <div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
{split ? ( {split ? (
<> <>
<span className={styles.shelfGroupHead}>Dimensions</span> {/* The visible count says "there's more below the fold" — the grouped
shelf only appears for wide datasets, where the list scrolls. */}
<span className={styles.shelfGroupHead}>Dimensions · {dimensions.length}</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div> <div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span> <span className={styles.shelfGroupHead}>Measures · {measures.length + 1}</span>
<div className={styles.shelfList}> <div className={styles.shelfList}>
{measures.map(fieldButton)} {measures.map(fieldButton)}
{countButton} {countButton}
@@ -618,6 +622,8 @@ function MarksCard() {
/** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4. /** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4.
* Shown so the layout telegraphs where row/column faceting will live. */ * Shown so the layout telegraphs where row/column faceting will live. */
// TODO: the "later" tag is internal roadmap language shown to users — hide the
// affordance until faceting ships, or reword for users (docs/ux-second-pass.md).
function FacetSlot({ kind }: { kind: 'column' | 'row' }) { function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
return ( return (
<div className={styles.facetSlot} title="Faceting → small multiples (coming later)"> <div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
@@ -776,14 +782,9 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
triggerContent={filter.field ? undefined : 'Choose a field…'} triggerContent={filter.field ? undefined : 'Choose a field…'}
/> />
)} )}
<button <IconButton size="sm" label="Remove filter" onClick={() => removeFilter(filter.id)}>
type="button"
className={styles.removeRow}
aria-label="Remove filter"
onClick={() => removeFilter(filter.id)}
>
<Icon name="close" /> <Icon name="close" />
</button> </IconButton>
</div> </div>
{!expressionMode && ( {!expressionMode && (
@@ -876,14 +877,13 @@ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: Buil
aria-describedby={exprMsgId} aria-describedby={exprMsgId}
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })} onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
/> />
<button <IconButton
type="button" size="sm"
className={styles.removeRow} label="Remove calculated field"
aria-label="Remove calculated field"
onClick={() => removeCalculate(calc.id)} onClick={() => removeCalculate(calc.id)}
> >
<Icon name="close" /> <Icon name="close" />
</button> </IconButton>
</div> </div>
<ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} /> <ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} />
</div> </div>
@@ -1303,16 +1303,16 @@ function NoDatasets() {
The Chart Builder turns a saved dataset into a chart pick columns, watch the chart take The Chart Builder turns a saved dataset into a chart pick columns, watch the chart take
shape, and save it as a snippet. Add a dataset to start building. shape, and save it as a snippet. Add a dataset to start building.
</p> </p>
<button <Button
type="button" variant="primary"
className={`${styles.action} ${styles.primary}`} size="lg"
onClick={() => { onClick={() => {
openModal('datasets'); openModal('datasets');
useDatasetStore.getState().startCreate(); useDatasetStore.getState().startCreate();
}} }}
> >
Add a dataset Add a dataset
</button> </Button>
</div> </div>
); );
} }
@@ -1470,12 +1470,12 @@ export function ChartBuilderModal() {
</p> </p>
)} )}
<div className={styles.actions}> <div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}> <Button size="lg" onClick={() => void closeModal()}>
Cancel Cancel
</button> </Button>
<button <Button
type="button" variant="primary"
className={`${styles.action} ${styles.primary}`} size="lg"
disabled={!valid} disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined} aria-describedby={!valid ? 'cb-create-hint' : undefined}
// The create is the user's confirmation — close with no discard prompt. // The create is the user's confirmation — close with no discard prompt.
@@ -1484,7 +1484,7 @@ export function ChartBuilderModal() {
}} }}
> >
Create Snippet Create Snippet
</button> </Button>
</div> </div>
</div> </div>