Theme Builder: structured color/type controls, scheme catalog, diverging preview

This commit is contained in:
2026-06-14 01:25:44 +03:00
parent 0470389b41
commit e4d1466ab0
17 changed files with 1607 additions and 52 deletions
+143
View File
@@ -0,0 +1,143 @@
/* Theme Builder Color panel — structured controls over the draft config. */
.panel {
display: grid;
gap: var(--space-6);
padding: var(--space-5);
overflow: auto;
}
.group {
display: grid;
gap: var(--space-3);
}
.groupTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.caret {
color: var(--text-secondary);
margin-left: var(--space-3);
}
/* Editable swatch rows (palette colors / gradient stops) wrap into a grid. */
.swatches {
display: flex;
align-items: center;
gap: var(--space-3) var(--space-4);
flex-wrap: wrap;
}
/* One swatch: color picker + copyable hex field, with a hover remove. */
.swatchUnit {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.hexInput {
width: 8ch;
height: var(--control-height);
padding: 0 var(--space-2);
font-family: var(--font-mono);
font-size: 12px;
}
/* Native color input, sized to a swatch and stripped of its chrome. */
.colorInput {
width: 32px;
height: 32px;
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.colorInput::-webkit-color-swatch-wrapper {
padding: 2px;
}
.colorInput::-webkit-color-swatch {
border: none;
}
.colorInput::-moz-color-swatch {
border: none;
}
.swatchRemove {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 12px;
line-height: 1;
color: var(--text);
background: var(--layer-02);
border: var(--border-width) solid var(--border-strong);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
.swatchUnit:hover .swatchRemove,
.swatchRemove:focus-visible {
opacity: 1;
}
.gradientPreview {
width: 96px;
height: 24px;
flex: 0 0 auto;
border: var(--border-width) solid var(--border-strong);
}
/* Dropdown-option previews: a swatch strip (categorical) / gradient bar. */
.optStrip {
display: inline-flex;
width: 84px;
}
.optSwatch {
flex: 1 1 0;
height: 14px;
min-width: 4px;
}
.optGradient {
display: inline-block;
width: 84px;
height: 14px;
border: var(--border-width) solid var(--border);
}
/* Mini current-value preview inside a SelectControl trigger. */
.triggerPreview {
display: inline-flex;
margin-right: var(--space-2);
}
.triggerPreview .optSwatch {
width: 6px;
flex: 0 0 6px;
height: 14px;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* Color panel — behavioural wiring through the live modal. The pure config
* transforms are covered in core/theme-controls.test.ts; these confirm the
* controls read the draft config and write back through `mutateDraftConfig`
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
* vega-embed is mocked (the gallery is integration-heavy).
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { JsonObject } from '@core/spec-config';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { usePopoverStore } from '../stores/PopoverStore';
import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
useCustomThemeStore.getState().reset();
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
const render = () => act(() => root.render(<ThemeBuilderModal />));
/** Open a draft seeded with `config` (block body so `act` returns void). */
const open = (config: JsonObject) =>
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
/** Drive an input's value through the native setter so React's value tracker
* registers the change and fires onChange (a bare `input.value =` doesn't). */
function setNativeValue(el: HTMLInputElement, value: string) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
const button = (label: string) =>
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
const picker = (labelPrefix: string) =>
[...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(labelPrefix),
);
describe('ColorControls', () => {
test('materialize expands a named scheme into an editable array', () => {
render();
open({ range: { category: 'tableau10' } });
expect(range().category).toBe('tableau10');
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().category)).toBe(true);
expect((range().category as string[]).length).toBe(10);
});
test('add and remove palette colors', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
act(() => button('Add color')!.click());
expect((range().category as string[]).length).toBe(3);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect(range().category).toEqual(['#222222', '#888888']);
});
test('removing the last swatch deletes range.category', () => {
render();
open({ range: { category: ['#111111'] } });
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Palette color 1"]',
)!;
act(() => remove.click());
expect('category' in range()).toBe(false);
});
test('setting a default mark color writes mark.color; clear removes it', () => {
render();
open({});
const input = container.querySelector<HTMLInputElement>(
'input[aria-label="Default mark color"]',
)!;
act(() => setNativeValue(input, '#ff0000'));
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
act(() => button('Clear')!.click());
expect(draftConfig()?.mark).toBeUndefined();
});
test('typing in a swatch hex field updates that color', () => {
render();
open({ range: { category: ['#111111', '#222222'] } });
const hex = container.querySelector<HTMLInputElement>(
'input[aria-label="Palette color 1 hex value"]',
)!;
act(() => setNativeValue(hex, '#abcdef'));
expect((range().category as string[])[0]).toBe('#abcdef');
});
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
render();
// Seed the other families as arrays so the only "Materialize" button is the
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
open({
range: {
category: ['#111111'],
heatmap: 'viridis',
ramp: 'viridis',
diverging: ['#aa0000', '#0000aa'],
},
});
act(() => button('Materialize to edit')!.click());
expect(Array.isArray(range().heatmap)).toBe(true);
expect(range().ramp).toEqual(range().heatmap);
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove Sequential stop 1"]',
)!;
const before = (range().heatmap as string[]).length;
act(() => remove.click());
expect((range().heatmap as string[]).length).toBe(before - 1);
expect(range().ramp).toEqual(range().heatmap);
});
test('scheme dropdown options carry a color preview', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
const tableau = button('Tableau 10')!;
// The decorative swatch strip renders as spans inside the option button.
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
});
test('picking a scheme writes range.category as a string', () => {
render();
open({});
act(() => picker('Categorical color scheme')!.click());
act(() => button('Category 10')!.click());
expect(range().category).toBe('category10');
});
test('a sequential pick sets both heatmap and ramp', () => {
render();
open({});
act(() => picker('Sequential color scheme')!.click());
act(() => button('Viridis')!.click());
expect(range().heatmap).toBe('viridis');
expect(range().ramp).toBe('viridis');
});
test('invalid JSON disables the controls', () => {
render();
open({});
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.textContent).toContain('Fix the JSON below to use these controls');
expect(button('Materialize to edit')).toBeUndefined();
});
test('the Type tab exposes the font-apply control', () => {
render();
open({});
const typeTab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
)!;
act(() => typeTab.click());
expect(picker('Font family')).toBeTruthy();
});
test('font options render their name in their own family', () => {
render();
open({});
const typeTab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
)!;
act(() => typeTab.click());
act(() => picker('Font family')!.click());
const georgia = button('Georgia')!;
expect(georgia.querySelector<HTMLElement>('span')!.style.fontFamily).toContain('Georgia');
});
test('the raw JSON is collapsed by default and toggles open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-controls="theme-config"]',
)!;
expect(toggle.getAttribute('aria-expanded')).toBe('false');
act(() => toggle.click());
expect(container.querySelector('#theme-config')).toBeTruthy();
});
test('a parse error forces the JSON open', () => {
render();
open({});
expect(container.querySelector('#theme-config')).toBeNull();
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
expect(container.querySelector('#theme-config')).toBeTruthy();
expect(
container
.querySelector('button[aria-controls="theme-config"]')!
.getAttribute('aria-expanded'),
).toBe('true');
});
});
+419
View File
@@ -0,0 +1,419 @@
/**
* Theme Builder — Color panel (docs/chart-theming-scope.md §5).
*
* Structured controls over the draft config's color surface: the categorical
* palette (`range.category`), the default single-series mark color
* (`mark.color`), and the sequential/diverging gradients (`range.heatmap`+`ramp`
* and `range.diverging`). Each writes through `mutateDraftConfig` so the JSON
* and the gallery follow; reads come from the parsed draft config, so a JSON
* hand-edit reflects straight back into the controls.
*
* Color model (§5): every family holds either a named Vega scheme (compact, the
* quick path) or an explicit color array (custom tuning). Picking a scheme from
* the preview-bearing dropdown writes the name; "Materialize" expands it to an
* editable array of swatches — categorical, sequential, and diverging alike.
* Each swatch pairs the native color picker with a hex text field, so a value
* can be read, copied, and retyped anywhere.
*/
import { useState, type ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config';
import {
type ConfigPath,
getConfigValue,
schemeColors,
schemesByKind,
setConfigValue,
} from '@core/theme-controls';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { Button } from './Button';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ColorControls.module.css';
const CATEGORY: ConfigPath = ['range', 'category'];
const MARK_COLOR: ConfigPath = ['mark', 'color'];
const HEATMAP: ConfigPath = ['range', 'heatmap'];
const RAMP: ConfigPath = ['range', 'ramp'];
const DIVERGING: ConfigPath = ['range', 'diverging'];
/** Vega-Lite's default mark color — shown as the starting value when unset. */
const VEGA_DEFAULT_MARK = '#4c78a8';
/** Seeded into a new swatch and the materialize-from-default paths. */
const NEW_SWATCH = '#888888';
const DEFAULT_CATEGORICAL = 'tableau10';
const DEFAULT_SEQUENTIAL = 'viridis';
const DEFAULT_DIVERGING = 'redblue';
/** Stops a materialized gradient starts with (continuous schemes are sampled). */
const GRADIENT_STOPS = 7;
const isHex = (c: string): boolean => /^#[0-9a-f]{6}$/i.test(c);
const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
function normalizeHex(raw: string): string | null {
const v = raw.trim().replace(/^#/, '');
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
}
const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
// Dropdown previews, built once: a swatch strip for categorical, a gradient bar
// for the continuous families.
const swatchStrip = (colors: string[]): ReactNode => (
<span className={styles.optStrip}>
{colors.slice(0, 12).map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
);
const gradientBar = (colors: string[]): ReactNode => (
<span className={styles.optGradient} style={{ background: gradientCss(colors) }} />
);
const CATEGORICAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('categorical').map(
(s) => ({
value: s.name,
label: s.label,
preview: swatchStrip(schemeColors(s.name)),
}),
);
const SEQUENTIAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('sequential').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('diverging').map((s) => ({
value: s.name,
label: s.label,
preview: gradientBar(schemeColors(s.name, 12)),
}));
/** A color picker paired with a copyable, editable hex field; optional remove. */
function SwatchRow({
color,
label,
onChange,
onRemove,
}: {
color: string;
label: string;
onChange: (hex: string) => void;
onRemove?: () => void;
}) {
// Local text so a partially-typed hex isn't rejected mid-keystroke; commits on
// a valid value, and reverts to the committed color on blur. Resync to the
// committed color when it changes from the outside (color picker, materialize,
// scheme) — but not from this field's own commit, so an in-progress edit isn't
// clobbered. The render-time adjustment is React's documented alternative to a
// sync effect.
const [text, setText] = useState(color);
const [synced, setSynced] = useState(color);
if (color !== synced) {
setSynced(color);
if (normalizeHex(text) !== color) setText(color);
}
return (
<span className={styles.swatchUnit}>
<input
type="color"
className={styles.colorInput}
aria-label={label}
value={isHex(color) ? color : '#000000'}
onChange={(e) => onChange(e.target.value)}
/>
<input
type="text"
className={styles.hexInput}
aria-label={`${label} hex value`}
spellCheck={false}
value={text}
onChange={(e) => {
setText(e.target.value);
const norm = normalizeHex(e.target.value);
if (norm) onChange(norm);
}}
onBlur={() => setText(color)}
/>
{onRemove && (
// TODO: icon-only control as a raw <button> with a literal "×" rather
// than IconButton + <Icon name="close"/> (the controlled glyph vocabulary
// arch 09 §5 uses everywhere else). The corner badge is 16px — below
// IconButton's 24px sm and tighter than a 16px Icon fits — so neither
// primitive drops in unchanged. Reconcile the badge size with the
// primitive in the batched control-consistency pass.
<button
type="button"
className={styles.swatchRemove}
aria-label={`Remove ${label}`}
title="Remove"
onClick={onRemove}
>
×
</button>
)}
</span>
);
}
export function ColorControls({ config }: { config: JsonObject }) {
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
const setSeq = (value: unknown) =>
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value));
const catValue = getConfigValue(config, CATEGORY);
const catArray = asArray(catValue);
const catScheme = asString(catValue);
const markColor = asString(getConfigValue(config, MARK_COLOR));
const seqValue = getConfigValue(config, HEATMAP);
const seqArray = asArray(seqValue);
const seqScheme = asString(seqValue);
const divValue = getConfigValue(config, DIVERGING);
const divArray = asArray(divValue);
const divScheme = asString(divValue);
/** Stops to drive a gradient preview for a family in any of its states. */
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
array ?? (scheme ? schemeColors(scheme, 9) : []);
return (
<div className={styles.panel}>
{/* ── Categorical palette ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Categorical palette</h4>
<p className={styles.hint}>Series colors assigned to discrete categories in order.</p>
<div className={styles.row}>
<SelectControl
id="color-categorical-scheme"
label="Categorical color scheme"
heading="Color scheme"
options={CATEGORICAL_OPTIONS}
value={catScheme ?? undefined}
onSelect={(name) => set(CATEGORY, name)}
triggerContent={
<>
<span className={styles.triggerPreview} aria-hidden="true">
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
.slice(0, 6)
.map((c, i) => (
<span key={i} className={styles.optSwatch} style={{ background: c }} />
))}
</span>
<span>
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
triggerTitle="Pick a named palette"
/>
{catArray ? (
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
Add color
</Button>
) : (
<Button
variant="ghost"
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
>
Materialize to edit
</Button>
)}
</div>
{catArray && (
<div className={styles.swatches}>
{catArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Palette color ${i + 1}`}
onChange={(hex) =>
set(
CATEGORY,
catArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
CATEGORY,
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</section>
{/* ── Default mark color ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Default mark color</h4>
<p className={styles.hint}>
Single-series fill bars, points, and lines with no color encoding.
</p>
<div className={styles.row}>
<SwatchRow
color={markColor && isHex(markColor) ? markColor : VEGA_DEFAULT_MARK}
label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)}
/>
{markColor ? (
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
Clear
</Button>
) : (
<span className={styles.hint}>Unset Vega default ({VEGA_DEFAULT_MARK})</span>
)}
</div>
</section>
{/* ── Sequential gradient ─────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Sequential gradient</h4>
<p className={styles.hint}>Continuous color heatmaps and quantitative legends.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
/>
<SelectControl
id="color-sequential-scheme"
label="Sequential color scheme"
heading="Sequential scheme"
options={SEQUENTIAL_OPTIONS}
value={seqScheme ?? undefined}
onSelect={(name) => setSeq(name)}
triggerContent={
<>
<span>
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{seqArray ? (
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() => setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))}
>
Materialize to edit
</Button>
)}
{(seqArray || seqScheme) && (
<Button variant="ghost" onClick={() => setSeq(undefined)}>
Clear
</Button>
)}
</div>
{seqArray && (
<div className={styles.swatches}>
{seqArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Sequential stop ${i + 1}`}
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
onRemove={() =>
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
}
/>
))}
</div>
)}
</section>
{/* ── Diverging gradient ──────────────────────────────────────────── */}
<section className={styles.group}>
<h4 className={styles.groupTitle}>Diverging gradient</h4>
<p className={styles.hint}>Two-ended color values around a meaningful midpoint.</p>
<div className={styles.row}>
<span
className={styles.gradientPreview}
aria-hidden="true"
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
/>
<SelectControl
id="color-diverging-scheme"
label="Diverging color scheme"
heading="Diverging scheme"
options={DIVERGING_OPTIONS}
value={divScheme ?? undefined}
onSelect={(name) => set(DIVERGING, name)}
triggerContent={
<>
<span>
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
}
/>
{divArray ? (
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
Add stop
</Button>
) : (
<Button
variant="ghost"
onClick={() =>
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
}
>
Materialize to edit
</Button>
)}
{(divArray || divScheme) && (
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
Clear
</Button>
)}
</div>
{divArray && (
<div className={styles.swatches}>
{divArray.map((color, i) => (
<SwatchRow
key={i}
color={color}
label={`Diverging stop ${i + 1}`}
onChange={(hex) =>
set(
DIVERGING,
divArray.map((c, j) => (j === i ? hex : c)),
)
}
onRemove={() =>
set(
DIVERGING,
divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i),
)
}
/>
))}
</div>
)}
</section>
</div>
);
}
@@ -127,9 +127,17 @@
.optionLabel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
/* Decorative leading visual (e.g. a color-scheme swatch strip / gradient bar). */
.optionPreview {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.detail {
font-size: 11px;
font-weight: 400;
+17 -2
View File
@@ -22,7 +22,7 @@
* caller intercept the click entirely (the armed-channel fast path).
*/
import { Fragment, type ReactNode } from 'react';
import { Fragment, type CSSProperties, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePopover } from '../hooks/usePopover';
import styles from './SelectControl.module.css';
@@ -47,6 +47,16 @@ export interface SelectControlOption<V extends string> {
* activating discloses further UI, matching the "…" label convention.
*/
hasPopup?: 'dialog';
/**
* Optional visual leading the option label — e.g. a color-scheme swatch strip
* or gradient bar. Decorative (`aria-hidden`); the label carries the meaning.
*/
preview?: ReactNode;
/**
* Style applied to the label text — e.g. render a font option's name in its
* own family, so the option previews the value (the type analogue of a swatch).
*/
labelStyle?: CSSProperties;
}
export interface SelectControlProps<V extends string> {
@@ -171,7 +181,12 @@ export function SelectControl<V extends string>({
aria-haspopup={o.hasPopup}
onClick={() => choose(o.value)}
>
<span className={styles.optionLabel}>
{o.preview !== undefined && (
<span className={styles.optionPreview} aria-hidden="true">
{o.preview}
</span>
)}
<span className={styles.optionLabel} style={o.labelStyle}>
{o.label}
{o.detail !== undefined && (
<span className={styles.detail}>{o.detail}</span>
+123 -9
View File
@@ -141,31 +141,145 @@
border-bottom: var(--border-width) solid var(--border);
}
/* ── Editor + gallery split ────────────────────────────────────────────── */
/* ── Structured-control tabs ───────────────────────────────────────────── */
.work {
.tabs {
flex: 0 0 auto;
display: flex;
gap: 0;
padding: 0 var(--space-5);
border-bottom: var(--border-width) solid var(--border);
}
.tab {
appearance: none;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: var(--space-3) var(--space-4);
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.tab:hover {
color: var(--text);
}
.tabActive {
color: var(--text);
border-bottom-color: var(--accent);
}
/* The structured controls are the first-screen surface: they fill the column
and scroll, with the collapsible JSON section docked below. */
.tabpanel {
flex: 1 1 auto;
overflow: auto;
min-height: 0;
border-bottom: var(--border-width) solid var(--border);
}
.controlsDisabled {
margin: 0;
padding: var(--space-5);
font-size: 13px;
color: var(--text-secondary);
}
.typePanel {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
}
.typeTitle {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.typeHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.fontCaret {
margin-left: var(--space-3);
font-size: 10px;
color: var(--text-secondary);
}
/* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
/* The gallery takes the whole right side, full height; the controls column —
tabs, the active panel, and the JSON editor stacked — sits on the left,
capped so the previews get the room. */
.body {
flex: 1 1 auto;
display: grid;
grid-template-columns: minmax(280px, 400px) 1fr;
grid-template-columns: clamp(420px, 38%, 600px) 1fr;
grid-template-rows: minmax(0, 1fr);
min-height: 0;
min-width: 0;
}
.controlsCol {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border-right: var(--border-width) solid var(--border);
}
/* Collapsible raw-JSON disclosure, docked at the bottom of the controls column. */
.jsonSection {
flex: 0 0 auto;
display: flex;
flex-direction: column;
border-top: var(--border-width) solid var(--border);
}
.jsonToggle {
appearance: none;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: none;
border: none;
font: inherit;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.jsonToggle:hover {
color: var(--text);
}
.jsonCaret {
font-size: 10px;
}
.editorPane {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-right: var(--border-width) solid var(--border);
min-height: 0;
padding: 0 var(--space-5) var(--space-4);
min-width: 0;
}
.configText {
flex: 1 1 auto;
width: 100%;
min-height: 0;
height: clamp(180px, 34vh, 340px);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
+155 -34
View File
@@ -11,7 +11,7 @@
* is exactly what selecting the theme will do.
*/
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
import type { JsonObject } from '@core/spec-config';
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
@@ -27,9 +27,28 @@ import {
import { notify } from '../stores/NotificationStore';
import { resnapshot } from '../modals/ModalCoordinator';
import { Button } from './Button';
import { SelectControl } from './SelectControl';
import { ColorControls } from './ColorControls';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ThemeBuilderModal.module.css';
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
const THEME_TABS = [
{ id: 'color', label: 'Color' },
{ id: 'type', label: 'Type' },
] as const;
type ThemeTab = (typeof THEME_TABS)[number]['id'];
/**
* Font options, each labelled in its own family so the dropdown previews the
* typeface (the type analogue of the color dropdowns' swatches). Render-safe
* faces only today — see THEME_FONT_OPTIONS.
*/
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
value,
label,
labelStyle: { fontFamily: value },
}));
/** Debounce for gallery re-renders while the config text is edited (ms). */
const GALLERY_DEBOUNCE = 250;
@@ -108,6 +127,35 @@ export function ThemeBuilderModal() {
const saveError = useCustomThemeStore((s) => s.saveError);
const dirty = useCustomThemeStore(selectIsDraftDirty);
const selectedTheme = useCustomThemeStore(selectSelectedTheme);
const [activeTab, setActiveTab] = useState<ThemeTab>('color');
// The raw JSON is the advanced/escape-hatch surface, collapsed off the first
// screen so the structured controls own it; a parse error forces it open
// (it's the only place to fix the JSON).
const [jsonExpanded, setJsonExpanded] = useState(false);
const jsonOpen = jsonExpanded || parseError !== null;
// The font option matching the config's top-level font (if any) — drives the
// Type tab's selected state and its in-face trigger label.
const currentFont =
typeof draftConfig?.font === 'string'
? FONT_OPTIONS.find((o) => o.value === draftConfig.font)
: undefined;
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
// activation — the panel swap is cheap). The portaled SelectControl popovers
// inside a panel manage their own focus.
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const ids = THEME_TABS.map((t) => t.id);
const i = ids.indexOf(activeTab);
let next = -1;
if (e.key === 'ArrowRight') next = (i + 1) % ids.length;
else if (e.key === 'ArrowLeft') next = (i - 1 + ids.length) % ids.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = ids.length - 1;
if (next < 0) return;
e.preventDefault();
setActiveTab(ids[next]);
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
};
const handleNew = () => {
const app = useAppStore.getState();
@@ -199,15 +247,6 @@ export function ThemeBuilderModal() {
}
/>
</div>
<SelectControl
id="theme-builder-font"
label="Apply a font across the config"
heading="Apply font"
options={THEME_FONT_OPTIONS.map(({ value, label }) => ({ value, label }))}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={<>Font</>}
triggerTitle="Write one font family into every font slot of the config"
/>
<div className={styles.toolbarEnd}>
<Button variant="danger-outline" onClick={() => void handleDelete()}>
Delete
@@ -222,30 +261,112 @@ export function ThemeBuilderModal() {
</div>
</div>
{(saveError ?? parseError) !== null && (
<p className={styles.errorMessage} role="alert">
{saveError ?? parseError}
</p>
)}
<div className={styles.body}>
<div className={styles.controlsCol}>
<div
className={styles.tabs}
role="tablist"
aria-label="Theme controls"
onKeyDown={onTabKeyDown}
>
{THEME_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`theme-tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`theme-tabpanel-${t.id}`}
tabIndex={activeTab === t.id ? 0 : -1}
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className={styles.work}>
<div className={styles.editorPane}>
<label className={styles.label} htmlFor="theme-config">
Config (Vega-Lite JSON)
</label>
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
is heavy inside a modal for an occasional surface, and the parse
error below is the feedback channel that matters here. Revisit
only if real usage asks for config completions. */}
<textarea
id="theme-config"
className={styles.configText}
spellCheck={false}
value={draft.configText}
onChange={(e) =>
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
}
/>
{(saveError ?? parseError) !== null && (
<p className={styles.errorMessage} role="alert">
{saveError ?? parseError}
</p>
)}
<div
className={styles.tabpanel}
role="tabpanel"
id={`theme-tabpanel-${activeTab}`}
aria-labelledby={`theme-tab-${activeTab}`}
>
{parseError !== null ? (
<p className={styles.controlsDisabled}>
Fix the JSON below to use these controls.
</p>
) : activeTab === 'color' && draftConfig !== null ? (
<ColorControls config={draftConfig} />
) : activeTab === 'type' ? (
<div className={styles.typePanel}>
<h4 className={styles.typeTitle}>Font family</h4>
<p className={styles.typeHint}>
Writes one family into every font slot of the config.
</p>
<SelectControl
id="theme-builder-font"
label="Font family"
heading="Apply font"
options={FONT_OPTIONS}
value={currentFont?.value}
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
triggerContent={
<>
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
{currentFont?.label ?? 'Apply font…'}
</span>
<span className={styles.fontCaret} aria-hidden="true">
</span>
</>
}
triggerTitle="Write one font family into every font slot of the config"
/>
</div>
) : null}
</div>
{/* Raw JSON — collapsed by default (the structured controls are the
first-screen surface); forced open while a parse error is the
only thing that needs the textarea. */}
<div className={styles.jsonSection}>
<button
type="button"
className={styles.jsonToggle}
aria-expanded={jsonOpen}
aria-controls="theme-config"
onClick={() => setJsonExpanded((o) => !o)}
>
<span className={styles.jsonCaret} aria-hidden="true">
{jsonOpen ? '▾' : '▸'}
</span>
Config (Vega-Lite JSON)
</button>
{jsonOpen && (
<div className={styles.editorPane}>
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
is heavy inside a modal for an occasional surface, and the parse
error above is the feedback channel that matters here. Revisit
only if real usage asks for config completions. */}
<textarea
id="theme-config"
className={styles.configText}
spellCheck={false}
value={draft.configText}
onChange={(e) =>
useCustomThemeStore.getState().updateDraft({ configText: e.target.value })
}
/>
</div>
)}
</div>
</div>
<div className={styles.gallery} aria-label="Theme preview gallery">
{draftConfig !== null &&