mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Theme Builder — Axes & grid panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the base `axis` config only — grid visibility, grid
|
||||
* color and dash style, the domain line, label color and angle, and title
|
||||
* color. The 25 per-channel variants (`axisX`, `axisY`, `axisBand`, …) stay in
|
||||
* the JSON; this is the common surface a brand actually tunes. Axis *type*
|
||||
* (label/title size and weight) lives in the Type panel.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
type ConfigPath,
|
||||
getConfigValue,
|
||||
} from '@core/theme-controls';
|
||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import type { SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const GRID: ConfigPath = ['axis', 'grid'];
|
||||
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
|
||||
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
|
||||
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
|
||||
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
|
||||
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
|
||||
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
|
||||
|
||||
const GRID_GREY = '#888888';
|
||||
|
||||
// Grid visibility: tri-state (theme default · shown · hidden) over a boolean.
|
||||
type GridState = '' | 'true' | 'false';
|
||||
const gridOptions: SelectControlOption<GridState>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: 'true', label: 'Shown' },
|
||||
{ value: 'false', label: 'Hidden' },
|
||||
];
|
||||
const gridState = (v: boolean | undefined): GridState =>
|
||||
v === undefined ? '' : v ? 'true' : 'false';
|
||||
|
||||
// Dash presets, matched by array shape; an unrecognised array reads as no preset
|
||||
// (the trigger shows "—") so the control never misreports a hand-authored dash.
|
||||
type DashStyle = '' | 'solid' | 'dotted' | 'dashed' | 'custom';
|
||||
const dashOptions: SelectControlOption<DashStyle>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: 'solid', label: 'Solid' },
|
||||
{ value: 'dotted', label: 'Dotted' },
|
||||
{ value: 'dashed', label: 'Dashed' },
|
||||
];
|
||||
const DASH_VALUES: Record<Exclude<DashStyle, '' | 'custom'>, number[]> = {
|
||||
solid: [],
|
||||
dotted: [2, 2],
|
||||
dashed: [6, 3],
|
||||
};
|
||||
const dashStyle = (v: unknown): DashStyle => {
|
||||
if (v === undefined) return '';
|
||||
if (!Array.isArray(v)) return 'custom';
|
||||
if (v.length === 0) return 'solid';
|
||||
if (v.length === 2 && v[0] === 2 && v[1] === 2) return 'dotted';
|
||||
if (v.length === 2 && v[0] === 6 && v[1] === 3) return 'dashed';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
export function AxesControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
|
||||
const grid = asBoolean(getConfigValue(config, GRID));
|
||||
const gridColor = asString(getConfigValue(config, GRID_COLOR));
|
||||
const dash = dashStyle(getConfigValue(config, GRID_DASH));
|
||||
const domainColor = asString(getConfigValue(config, DOMAIN_COLOR));
|
||||
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
|
||||
const labelAngle = asNumber(getConfigValue(config, LABEL_ANGLE));
|
||||
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Grid" hint="Reference lines behind the marks.">
|
||||
<SelectRow
|
||||
id="axes-grid"
|
||||
label="Grid lines"
|
||||
options={gridOptions}
|
||||
value={gridState(grid)}
|
||||
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Grid color"
|
||||
value={gridColor}
|
||||
fallback={GRID_GREY}
|
||||
onChange={(hex) => set(GRID_COLOR, hex)}
|
||||
onClear={() => set(GRID_COLOR, undefined)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="axes-grid-dash"
|
||||
label="Grid style"
|
||||
options={dashOptions}
|
||||
value={dash}
|
||||
onSelect={(s) =>
|
||||
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
|
||||
}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Domain & labels" hint="The axis line, its tick labels, and title.">
|
||||
<ColorRow
|
||||
label="Domain line"
|
||||
value={domainColor}
|
||||
fallback={GRID_GREY}
|
||||
onChange={(hex) => set(DOMAIN_COLOR, hex)}
|
||||
onClear={() => set(DOMAIN_COLOR, undefined)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Label color"
|
||||
value={labelColor}
|
||||
fallback={GRID_GREY}
|
||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||
onClear={() => set(LABEL_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Label angle"
|
||||
value={labelAngle}
|
||||
min={-90}
|
||||
max={90}
|
||||
unit="°"
|
||||
onChange={(n) => set(LABEL_ANGLE, n)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Title color"
|
||||
value={titleColor}
|
||||
fallback={GRID_GREY}
|
||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||
onClear={() => set(TITLE_COLOR, undefined)}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -170,25 +170,26 @@ describe('ColorControls', () => {
|
||||
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('picking a scheme writes range.category as a string', () => {
|
||||
test('picking a scheme writes range.category as a Vega scheme object', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
act(() => button('Category 10')!.click());
|
||||
|
||||
expect(range().category).toBe('category10');
|
||||
// The `{ scheme }` object — a bare scheme-name string is rejected at render.
|
||||
expect(range().category).toEqual({ scheme: 'category10' });
|
||||
});
|
||||
|
||||
test('a sequential pick sets both heatmap and ramp', () => {
|
||||
test('a sequential pick sets both heatmap and ramp to the scheme object', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Sequential color scheme')!.click());
|
||||
act(() => button('Viridis')!.click());
|
||||
|
||||
expect(range().heatmap).toBe('viridis');
|
||||
expect(range().ramp).toBe('viridis');
|
||||
expect(range().heatmap).toEqual({ scheme: 'viridis' });
|
||||
expect(range().ramp).toEqual({ scheme: 'viridis' });
|
||||
});
|
||||
|
||||
test('invalid JSON disables the controls', () => {
|
||||
|
||||
@@ -8,29 +8,25 @@
|
||||
* 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.
|
||||
* Color model (§5): every family holds either a named Vega scheme as the
|
||||
* range-scheme object `{ scheme: name }` (compact, the quick path) or an explicit
|
||||
* color array (custom tuning). Picking a scheme from the preview-bearing dropdown
|
||||
* writes that object (a bare string is rejected by Vega at render); "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 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 { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||
import styles from './ColorControls.module.css';
|
||||
|
||||
const CATEGORY: ConfigPath = ['range', 'category'];
|
||||
@@ -53,6 +49,22 @@ 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);
|
||||
|
||||
/**
|
||||
* A named scheme is stored in a `range` family as Vega's range-scheme object
|
||||
* `{ scheme: name }`. A bare scheme-name string compiles but is rejected by Vega
|
||||
* at render ("Unrecognized scale range value: …"), blanking the chart — so reads
|
||||
* accept either the object or a legacy/hand-authored bare string, while writes
|
||||
* (`schemeRange`) always use the object form.
|
||||
*/
|
||||
const asScheme = (v: unknown): string | null => {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v && typeof v === 'object' && typeof (v as { scheme?: unknown }).scheme === 'string') {
|
||||
return (v as { scheme: string }).scheme;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const schemeRange = (name: string): { scheme: string } => ({ scheme: name });
|
||||
|
||||
const gradientCss = (colors: string[]): string =>
|
||||
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
|
||||
|
||||
@@ -117,25 +129,26 @@ function SwatchRow({
|
||||
}
|
||||
|
||||
export function ColorControls({ config }: { config: JsonObject }) {
|
||||
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
|
||||
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
|
||||
const set = useConfigSetter();
|
||||
// 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 setSeq = (value: unknown) => {
|
||||
set(HEATMAP, value);
|
||||
set(RAMP, value);
|
||||
};
|
||||
|
||||
const catValue = getConfigValue(config, CATEGORY);
|
||||
const catArray = asArray(catValue);
|
||||
const catScheme = asString(catValue);
|
||||
const catScheme = asScheme(catValue);
|
||||
|
||||
const markColor = asString(getConfigValue(config, MARK_COLOR));
|
||||
|
||||
const seqValue = getConfigValue(config, HEATMAP);
|
||||
const seqArray = asArray(seqValue);
|
||||
const seqScheme = asString(seqValue);
|
||||
const seqScheme = asScheme(seqValue);
|
||||
|
||||
const divValue = getConfigValue(config, DIVERGING);
|
||||
const divArray = asArray(divValue);
|
||||
const divScheme = asString(divValue);
|
||||
const divScheme = asScheme(divValue);
|
||||
|
||||
/** Stops to drive a gradient preview for a family in any of its states. */
|
||||
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
|
||||
@@ -155,7 +168,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
|
||||
heading="Color scheme"
|
||||
options={CATEGORICAL_OPTIONS}
|
||||
value={catScheme ?? undefined}
|
||||
onSelect={(name) => set(CATEGORY, name)}
|
||||
onSelect={(name) => set(CATEGORY, schemeRange(name))}
|
||||
triggerContent={
|
||||
<>
|
||||
<span className={styles.triggerPreview} aria-hidden="true">
|
||||
@@ -252,7 +265,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
|
||||
heading="Sequential scheme"
|
||||
options={SEQUENTIAL_OPTIONS}
|
||||
value={seqScheme ?? undefined}
|
||||
onSelect={(name) => setSeq(name)}
|
||||
onSelect={(name) => setSeq(schemeRange(name))}
|
||||
triggerContent={
|
||||
<>
|
||||
<span>
|
||||
@@ -315,7 +328,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
|
||||
heading="Diverging scheme"
|
||||
options={DIVERGING_OPTIONS}
|
||||
value={divScheme ?? undefined}
|
||||
onSelect={(name) => set(DIVERGING, name)}
|
||||
onSelect={(name) => set(DIVERGING, schemeRange(name))}
|
||||
triggerContent={
|
||||
<>
|
||||
<span>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Theme Builder — Layout panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the draft config's surfaces and spacing: the chart
|
||||
* `background`, the plot area's `view` fill / border / corner radius, and outer
|
||||
* `padding`. Fill controls are tri-state (theme default · transparent/none ·
|
||||
* custom color) because "transparent" is a meaningful, distinct choice from
|
||||
* "unset" here — the house style sets `background` and `view.stroke` to
|
||||
* transparent, and stock Vega-Lite defaults them to white / `#ddd`.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { isJsonObject } from '@core/spec-config';
|
||||
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import type { SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const BACKGROUND: ConfigPath = ['background'];
|
||||
const VIEW_FILL: ConfigPath = ['view', 'fill'];
|
||||
const VIEW_STROKE: ConfigPath = ['view', 'stroke'];
|
||||
const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius'];
|
||||
const PADDING: ConfigPath = ['padding'];
|
||||
|
||||
type FillMode = 'default' | 'transparent' | 'custom';
|
||||
|
||||
const fillMode = (v: unknown): FillMode =>
|
||||
v === undefined ? 'default' : v === 'transparent' ? 'transparent' : 'custom';
|
||||
|
||||
const bgOptions: SelectControlOption<FillMode>[] = [
|
||||
{ value: 'default', label: 'Theme default' },
|
||||
{ value: 'transparent', label: 'Transparent' },
|
||||
{ value: 'custom', label: 'Custom color' },
|
||||
];
|
||||
// Same modes; "None" reads better than "Transparent" for a border.
|
||||
const strokeOptions: SelectControlOption<FillMode>[] = [
|
||||
{ value: 'default', label: 'Theme default' },
|
||||
{ value: 'transparent', label: 'None' },
|
||||
{ value: 'custom', label: 'Custom color' },
|
||||
];
|
||||
|
||||
const fillValue = (mode: FillMode, current: string | undefined, seed: string): unknown =>
|
||||
mode === 'default' ? undefined : mode === 'transparent' ? 'transparent' : (current ?? seed);
|
||||
|
||||
export function LayoutControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
|
||||
const bg = asString(getConfigValue(config, BACKGROUND));
|
||||
const bgMode = fillMode(getConfigValue(config, BACKGROUND));
|
||||
|
||||
const viewFill = asString(getConfigValue(config, VIEW_FILL));
|
||||
|
||||
const stroke = asString(getConfigValue(config, VIEW_STROKE));
|
||||
const strokeMode = fillMode(getConfigValue(config, VIEW_STROKE));
|
||||
|
||||
const radius = asNumber(getConfigValue(config, VIEW_RADIUS));
|
||||
|
||||
const padding = getConfigValue(config, PADDING);
|
||||
const paddingNum = asNumber(padding);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Background" hint="Fill behind the whole chart, padding included.">
|
||||
<SelectRow
|
||||
id="layout-bg-mode"
|
||||
label="Background"
|
||||
options={bgOptions}
|
||||
value={bgMode}
|
||||
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
|
||||
/>
|
||||
{bgMode === 'custom' && (
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Background color"
|
||||
value={bg}
|
||||
fallback="#ffffff"
|
||||
onChange={(hex) => set(BACKGROUND, hex)}
|
||||
/>
|
||||
)}
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Plot area" hint="The plotting rectangle inside the axes.">
|
||||
<ColorRow
|
||||
label="Fill"
|
||||
name="Plot area fill"
|
||||
value={viewFill}
|
||||
fallback="#ffffff"
|
||||
onChange={(hex) => set(VIEW_FILL, hex)}
|
||||
onClear={() => set(VIEW_FILL, undefined)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="layout-stroke-mode"
|
||||
label="Border"
|
||||
options={strokeOptions}
|
||||
value={strokeMode}
|
||||
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
|
||||
/>
|
||||
{strokeMode === 'custom' && (
|
||||
<ColorRow
|
||||
label="Border color"
|
||||
value={stroke}
|
||||
fallback="#cccccc"
|
||||
onChange={(hex) => set(VIEW_STROKE, hex)}
|
||||
/>
|
||||
)}
|
||||
<NumberRow
|
||||
label="Corner radius"
|
||||
value={radius}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(VIEW_RADIUS, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Spacing" hint="Margin between the chart and its container edge.">
|
||||
{isJsonObject(padding) ? (
|
||||
<p className={styles.hint}>
|
||||
Padding is set per-side as an object — edit it in the JSON below.
|
||||
</p>
|
||||
) : (
|
||||
<NumberRow
|
||||
label="Padding"
|
||||
value={paddingNum}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(PADDING, n)}
|
||||
/>
|
||||
)}
|
||||
</ControlSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Theme Builder — Legend panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the base `legend` config: placement (`orient`), the
|
||||
* title's and labels' color and size, and the symbol size. Legend type (size)
|
||||
* lives here rather than the Type panel so every legend property a brand tunes
|
||||
* sits together — the scope's "label/title color+size" grouping.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import type { SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const ORIENT: ConfigPath = ['legend', 'orient'];
|
||||
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
|
||||
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
|
||||
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
|
||||
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
|
||||
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
|
||||
|
||||
const TEXT_GREY = '#888888';
|
||||
|
||||
// Vega-Lite legend `orient` values; '' is the unset (theme default) sentinel.
|
||||
type Orient = '' | 'right' | 'left' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'none';
|
||||
const orientOptions: SelectControlOption<Orient>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
{ value: 'top-left', label: 'Top-left' },
|
||||
{ value: 'top-right', label: 'Top-right' },
|
||||
{ value: 'none', label: 'Hidden' },
|
||||
];
|
||||
const orientValue = (v: unknown): Orient => {
|
||||
const s = asString(v);
|
||||
return s !== undefined && orientOptions.some((o) => o.value === s) ? (s as Orient) : '';
|
||||
};
|
||||
|
||||
export function LegendControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
|
||||
const orient = orientValue(getConfigValue(config, ORIENT));
|
||||
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
|
||||
const titleSize = asNumber(getConfigValue(config, TITLE_SIZE));
|
||||
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
|
||||
const labelSize = asNumber(getConfigValue(config, LABEL_SIZE));
|
||||
const symbolSize = asNumber(getConfigValue(config, SYMBOL_SIZE));
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
|
||||
<SelectRow
|
||||
id="legend-orient"
|
||||
label="Position"
|
||||
options={orientOptions}
|
||||
value={orient}
|
||||
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Title">
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend title color"
|
||||
value={titleColor}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||
onClear={() => set(TITLE_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend title size"
|
||||
value={titleSize}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(TITLE_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Labels">
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend label color"
|
||||
value={labelColor}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||
onClear={() => set(LABEL_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend label size"
|
||||
value={labelSize}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(LABEL_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Symbols" hint="The colored keys beside each label.">
|
||||
<NumberRow
|
||||
label="Symbol size"
|
||||
value={symbolSize}
|
||||
min={0}
|
||||
unit="px²"
|
||||
onChange={(n) => set(SYMBOL_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,31 +191,6 @@
|
||||
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 —
|
||||
@@ -322,3 +297,20 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A render failure shows its message in place of the chart (fail-loud, arch 02),
|
||||
sized to the reserved card box so the gallery doesn't reflow. */
|
||||
.cardError {
|
||||
min-width: 260px;
|
||||
min-height: 180px;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--support-error);
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { renderSpec } from '../services/chart-renderer';
|
||||
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
),
|
||||
}));
|
||||
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
|
||||
|
||||
const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -26,6 +25,8 @@ let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(renderSpec).mockReset();
|
||||
vi.mocked(renderSpec).mockImplementation(() => Promise.resolve(okHandle()));
|
||||
useCustomThemeStore.getState().reset();
|
||||
useAppStore.getState().setChartTheme('astrolabe');
|
||||
container = document.createElement('div');
|
||||
@@ -110,6 +111,20 @@ describe('ThemeBuilderModal', () => {
|
||||
expect(container.textContent).toContain('Invalid JSON');
|
||||
});
|
||||
|
||||
test('a gallery card surfaces a render failure instead of blanking silently', async () => {
|
||||
vi.mocked(renderSpec).mockRejectedValue(new Error('Vega could not render this config'));
|
||||
renderModal();
|
||||
act(() => {
|
||||
useCustomThemeStore.getState().createTheme('Brand', {});
|
||||
});
|
||||
// Fire the gallery's render debounce and flush the async render chain.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(container.textContent).toContain('Vega could not render this config');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('selecting another theme from the list swaps the draft', () => {
|
||||
renderModal();
|
||||
act(() => {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { normalizeRangeSchemes } from '@core/theme-controls';
|
||||
import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
@@ -26,29 +26,24 @@ import {
|
||||
} from '../stores/CustomThemeStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { resnapshot } from '../modals/ModalCoordinator';
|
||||
import { AxesControls } from './AxesControls';
|
||||
import { Button } from './Button';
|
||||
import { ColorControls } from './ColorControls';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import { LayoutControls } from './LayoutControls';
|
||||
import { LegendControls } from './LegendControls';
|
||||
import { TypeControls } from './TypeControls';
|
||||
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' },
|
||||
{ id: 'layout', label: 'Layout' },
|
||||
{ id: 'axes', label: 'Axes & grid' },
|
||||
{ id: 'legend', label: 'Legend' },
|
||||
] 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;
|
||||
|
||||
@@ -58,14 +53,17 @@ const GALLERY_DEBOUNCE = 250;
|
||||
* modal; raster is invisible at swatch size (same trade-off as the Chart
|
||||
* Builder preview). Renders are serialized per card with the LivePreview
|
||||
* chain-lock pattern so a slow embed never interleaves with a newer one on the
|
||||
* shared host node. Render failures blank the card silently — the gallery is
|
||||
* a preview aid; the config editor's parse error is the real feedback channel.
|
||||
* shared host node. A render failure surfaces in the card as its message (the
|
||||
* same fail-loud treatment LivePreview gives the editor — arch 02), never a
|
||||
* silent blank: a config the user is editing that Vega can't render is exactly
|
||||
* the feedback they need.
|
||||
*/
|
||||
function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObject }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<RenderHandle | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const chainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
@@ -83,7 +81,10 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
|
||||
if (mine !== generationRef.current) return;
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
const handle = await renderSpec(node, card.spec, config, {
|
||||
// Heal a bare range scheme string (rejected by Vega at render) the same
|
||||
// way the live preview does — so a theme loaded with the old form still
|
||||
// previews. New control writes already use the `{ scheme }` object.
|
||||
const handle = await renderSpec(node, card.spec, normalizeRangeSchemes(config), {
|
||||
renderer: 'canvas',
|
||||
});
|
||||
if (mine !== generationRef.current) {
|
||||
@@ -91,8 +92,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
|
||||
return;
|
||||
}
|
||||
handleRef.current = handle;
|
||||
} catch {
|
||||
// Leave the card blank; the config editor reports the actionable error.
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// Don't bury it (arch 02 fail-loud): show the message in the card so a
|
||||
// config that renders on valid JSON but Vega rejects at runtime is
|
||||
// visible, not a silent blank.
|
||||
if (mine === generationRef.current) setError((err as Error).message);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
@@ -112,7 +117,12 @@ function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObj
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<div className={styles.cardHost} ref={hostRef} />
|
||||
{error !== null && (
|
||||
<pre className={styles.cardError} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
<div className={styles.cardHost} ref={hostRef} hidden={error !== null} />
|
||||
<figcaption className={styles.cardCaption}>{card.caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
@@ -133,12 +143,6 @@ export function ThemeBuilderModal() {
|
||||
// (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
|
||||
@@ -302,35 +306,17 @@ export function ThemeBuilderModal() {
|
||||
<p className={styles.controlsDisabled}>
|
||||
Fix the JSON below to use these controls.
|
||||
</p>
|
||||
) : activeTab === 'color' && draftConfig !== null ? (
|
||||
) : draftConfig === null ? null : activeTab === 'color' ? (
|
||||
<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}
|
||||
<TypeControls config={draftConfig} />
|
||||
) : activeTab === 'layout' ? (
|
||||
<LayoutControls config={draftConfig} />
|
||||
) : activeTab === 'axes' ? (
|
||||
<AxesControls config={draftConfig} />
|
||||
) : (
|
||||
<LegendControls config={draftConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON — collapsed by default (the structured controls are the
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Layout / Axes & grid / Legend / Type panels — behavioural wiring through the
|
||||
* live modal. The pure config transforms (path get/set, coercion) are covered in
|
||||
* core/theme-controls.test.ts; these confirm each panel reads the draft config
|
||||
* and writes the right path back through `mutateDraftConfig`, including the
|
||||
* minimal-diff delete (clearing a value removes the key). 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`, then switch to a structured-control tab. */
|
||||
const open = (config: JsonObject, tabLabel: string) => {
|
||||
act(() => {
|
||||
useCustomThemeStore.getState().createTheme('Brand', config);
|
||||
});
|
||||
const tab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === tabLabel,
|
||||
)!;
|
||||
act(() => tab.click());
|
||||
};
|
||||
|
||||
const config = () => useCustomThemeStore.getState().draftConfig as JsonObject;
|
||||
const at = (...path: string[]): unknown =>
|
||||
path.reduce<unknown>(
|
||||
(cur, key) =>
|
||||
cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[key] : undefined,
|
||||
config(),
|
||||
);
|
||||
|
||||
/** Drive an input's value through the native setter so React's tracker fires onChange. */
|
||||
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 }));
|
||||
}
|
||||
|
||||
const numberInput = (name: string) =>
|
||||
container.querySelector<HTMLInputElement>(`input[aria-label="${name}"]`)!;
|
||||
|
||||
/** 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);
|
||||
|
||||
/** Open the SelectControl whose accessible name starts with `prefix`, then pick `option`. */
|
||||
const pick = (prefix: string, option: string) => {
|
||||
const trigger = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.getAttribute('aria-label')?.startsWith(prefix),
|
||||
)!;
|
||||
act(() => trigger.click());
|
||||
act(() => button(option)!.click());
|
||||
};
|
||||
|
||||
describe('LayoutControls', () => {
|
||||
test('background mode "Transparent" writes background: transparent', () => {
|
||||
render();
|
||||
open({}, 'Layout');
|
||||
pick('Background', 'Transparent');
|
||||
expect(config().background).toBe('transparent');
|
||||
});
|
||||
|
||||
test('corner radius writes view.cornerRadius and clearing prunes the view object', () => {
|
||||
render();
|
||||
open({}, 'Layout');
|
||||
act(() => setNativeValue(numberInput('Corner radius'), '8'));
|
||||
expect(at('view', 'cornerRadius')).toBe(8);
|
||||
|
||||
act(() => setNativeValue(numberInput('Corner radius'), ''));
|
||||
expect(config().view).toBeUndefined();
|
||||
});
|
||||
|
||||
test('object padding shows the JSON hint instead of a number control', () => {
|
||||
render();
|
||||
open({ padding: { left: 5, top: 5 } }, 'Layout');
|
||||
expect(container.textContent).toContain('edit it in the JSON below');
|
||||
expect(container.querySelector('input[aria-label="Padding"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AxesControls', () => {
|
||||
test('grid lines "Hidden" writes axis.grid false', () => {
|
||||
render();
|
||||
open({}, 'Axes & grid');
|
||||
pick('Grid lines', 'Hidden');
|
||||
expect(at('axis', 'grid')).toBe(false);
|
||||
});
|
||||
|
||||
test('grid style "Dotted" writes a dash array; "Theme default" removes it', () => {
|
||||
render();
|
||||
open({}, 'Axes & grid');
|
||||
pick('Grid style', 'Dotted');
|
||||
expect(at('axis', 'gridDash')).toEqual([2, 2]);
|
||||
|
||||
pick('Grid style', 'Theme default');
|
||||
expect(at('axis', 'gridDash')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a negative label angle is accepted', () => {
|
||||
render();
|
||||
open({}, 'Axes & grid');
|
||||
act(() => setNativeValue(numberInput('Label angle'), '-45'));
|
||||
expect(at('axis', 'labelAngle')).toBe(-45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LegendControls', () => {
|
||||
test('position writes legend.orient', () => {
|
||||
render();
|
||||
open({}, 'Legend');
|
||||
pick('Position', 'Bottom');
|
||||
expect(at('legend', 'orient')).toBe('bottom');
|
||||
});
|
||||
|
||||
test('symbol size writes legend.symbolSize', () => {
|
||||
render();
|
||||
open({}, 'Legend');
|
||||
act(() => setNativeValue(numberInput('Symbol size'), '120'));
|
||||
expect(at('legend', 'symbolSize')).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeControls', () => {
|
||||
test('title weight "Bold" writes title.fontWeight 700', () => {
|
||||
render();
|
||||
open({}, 'Type');
|
||||
pick('Title weight', 'Bold');
|
||||
expect(at('title', 'fontWeight')).toBe(700);
|
||||
});
|
||||
|
||||
test('axis label size writes axis.labelFontSize', () => {
|
||||
render();
|
||||
open({}, 'Type');
|
||||
act(() => setNativeValue(numberInput('Axis label size'), '9'));
|
||||
expect(at('axis', 'labelFontSize')).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Theme Builder structured-control field primitives — shared by the Layout,
|
||||
Axes & grid, Legend, and Type panels (docs/chart-theming-scope.md §5). */
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Fields within a section stack with a label column for cross-row alignment. */
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: var(--control-height);
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 88px;
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.defaultNote {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Caret for a custom SelectControl trigger (e.g. the Type panel's in-face font). */
|
||||
.caret {
|
||||
margin-left: var(--space-3);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Theme Builder structured-control field primitives (docs/chart-theming-scope.md
|
||||
* §5).
|
||||
*
|
||||
* The Layout / Axes & grid / Legend / Type panels are forms of scalar controls
|
||||
* over the draft config — a labelled color, number, or enum per config key. This
|
||||
* is the small shared vocabulary they're built from, so the panels read
|
||||
* declaratively and look identical:
|
||||
*
|
||||
* - `ControlSection` — a titled group with an optional hint.
|
||||
* - `ColorRow` / `NumberRow` / `SelectRow` — one labelled control each.
|
||||
*
|
||||
* Each control reports a value or `undefined` (cleared); the panel writes it
|
||||
* through `mutateDraftConfig` + `setConfigValue`, where `undefined` deletes the
|
||||
* key for a minimal diff. The Color panel predates this module and keeps its own
|
||||
* swatch/gradient controls; these primitives cover the scalar surface the later
|
||||
* panels share. Leaf coercion (`asString`/`asNumber`/`asBoolean`) lives in core
|
||||
* `theme-controls.ts` with the path get/set it pairs with.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const slug = (s: string): string =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
|
||||
/**
|
||||
* A titled group of fields with an optional one-line hint. A `role="group"`
|
||||
* labelled by its heading, so a screen reader announces the group context when
|
||||
* entering it — which is what lets the rows inside carry short labels ("Size",
|
||||
* "Color") that repeat across sections without colliding (APG group pattern).
|
||||
*/
|
||||
export function ControlSection({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const headingId = `theme-group-${slug(title)}`;
|
||||
return (
|
||||
<section className={styles.group} role="group" aria-labelledby={headingId}>
|
||||
<h4 id={headingId} className={styles.groupTitle}>
|
||||
{title}
|
||||
</h4>
|
||||
{hint && <p className={styles.hint}>{hint}</p>}
|
||||
<div className={styles.fields}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled color. When unset, the swatch shows `fallback` (the rendered
|
||||
* default) with a "Default" note rather than a blank chip; once set, a Clear
|
||||
* deletes the key.
|
||||
*/
|
||||
export function ColorRow({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
fallback,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
label: string;
|
||||
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
|
||||
name?: string;
|
||||
value: string | undefined;
|
||||
fallback: string;
|
||||
onChange: (hex: string) => void;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
<div className={styles.control}>
|
||||
<ColorField value={value ?? fallback} label={name ?? label} onChange={onChange} hex />
|
||||
{value !== undefined ? (
|
||||
onClear && (
|
||||
<Button variant="ghost" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<span className={styles.defaultNote}>Default</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled number. Local text state so a transient entry (a lone "-", a
|
||||
* half-typed value) isn't rejected mid-keystroke — commits a finished number,
|
||||
* deletes the key when emptied, reverts to the committed value on blur. Resyncs
|
||||
* to an outside change (another control, a JSON edit) the same way ColorField's
|
||||
* hex field does.
|
||||
*/
|
||||
export function NumberRow({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit,
|
||||
}: {
|
||||
label: string;
|
||||
/** Accessible name when `label` is too generic to stand alone (e.g. "Size"). */
|
||||
name?: string;
|
||||
value: number | undefined;
|
||||
onChange: (value: number | undefined) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
}) {
|
||||
const [text, setText] = useState(value === undefined ? '' : String(value));
|
||||
const [synced, setSynced] = useState(value);
|
||||
if (value !== synced) {
|
||||
setSynced(value);
|
||||
const typed = text.trim() === '' ? undefined : Number(text);
|
||||
if (typed !== value) setText(value === undefined ? '' : String(value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
<div className={styles.control}>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.number}
|
||||
aria-label={name ?? label}
|
||||
value={text}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setText(raw);
|
||||
const t = raw.trim();
|
||||
if (t === '') onChange(undefined);
|
||||
else if (t !== '-' && Number.isFinite(Number(t))) onChange(Number(t));
|
||||
}}
|
||||
onBlur={() => setText(value === undefined ? '' : String(value))}
|
||||
/>
|
||||
{unit && <span className={styles.unit}>{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled enum, on the app's SelectControl. The caller maps the config value
|
||||
* to/from option strings; an unset config key is conventionally the `''` option
|
||||
* ("Theme default"), so the trigger always shows a current choice.
|
||||
*/
|
||||
export function SelectRow<V extends string>({
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Accessible name when `label` is too generic to stand alone (e.g. "Weight"). */
|
||||
name?: string;
|
||||
options: ReadonlyArray<SelectControlOption<V>>;
|
||||
value: V;
|
||||
onSelect: (value: V) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
<SelectControl
|
||||
id={id}
|
||||
label={name ?? label}
|
||||
options={options}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Theme Builder — Type panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* The font family (written into every font slot via `applyDraftFont`, the one
|
||||
* transform that walks the whole config) plus the type scale a brand tunes:
|
||||
* title size/weight and axis title/label size/weight. Legend type lives in the
|
||||
* Legend panel so all legend properties sit together. Color of text lives in
|
||||
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
|
||||
*/
|
||||
|
||||
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
||||
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const TITLE_SIZE: ConfigPath = ['title', 'fontSize'];
|
||||
const TITLE_WEIGHT: ConfigPath = ['title', 'fontWeight'];
|
||||
const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize'];
|
||||
const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight'];
|
||||
const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
|
||||
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
|
||||
|
||||
/**
|
||||
* Font options, each labelled in its own family so the dropdown previews the
|
||||
* typeface (the type analogue of the color dropdowns' swatches). The roster
|
||||
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate.
|
||||
*/
|
||||
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
labelStyle: { fontFamily: value },
|
||||
}));
|
||||
|
||||
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel.
|
||||
type Weight = '' | '400' | '500' | '600' | '700' | 'custom';
|
||||
const weightOptions: SelectControlOption<Weight>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: '400', label: 'Normal' },
|
||||
{ value: '500', label: 'Medium' },
|
||||
{ value: '600', label: 'Semibold' },
|
||||
{ value: '700', label: 'Bold' },
|
||||
];
|
||||
const weightValue = (v: unknown): Weight => {
|
||||
if (v === undefined) return '';
|
||||
if (typeof v === 'number') {
|
||||
const s = String(v) as Weight;
|
||||
return weightOptions.some((o) => o.value === s) ? s : 'custom';
|
||||
}
|
||||
// Named CSS weights map onto the two presets that have names.
|
||||
if (v === 'normal') return '400';
|
||||
if (v === 'bold') return '700';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
export function TypeControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
const currentFont =
|
||||
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined;
|
||||
|
||||
const setWeight = (path: ConfigPath) => (w: Weight) =>
|
||||
set(path, w === '' ? undefined : Number(w));
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Font</span>
|
||||
<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.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
</div>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Title">
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Title size"
|
||||
value={asNumber(getConfigValue(config, TITLE_SIZE))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(TITLE_SIZE, n)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="type-title-weight"
|
||||
label="Weight"
|
||||
name="Title weight"
|
||||
options={weightOptions}
|
||||
value={weightValue(getConfigValue(config, TITLE_WEIGHT))}
|
||||
onSelect={setWeight(TITLE_WEIGHT)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Axis titles">
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Axis title size"
|
||||
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="type-axis-title-weight"
|
||||
label="Weight"
|
||||
name="Axis title weight"
|
||||
options={weightOptions}
|
||||
value={weightValue(getConfigValue(config, AXIS_TITLE_WEIGHT))}
|
||||
onSelect={setWeight(AXIS_TITLE_WEIGHT)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Axis labels">
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Axis label size"
|
||||
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="type-axis-label-weight"
|
||||
label="Weight"
|
||||
name="Axis label weight"
|
||||
options={weightOptions}
|
||||
value={weightValue(getConfigValue(config, AXIS_LABEL_WEIGHT))}
|
||||
onSelect={setWeight(AXIS_LABEL_WEIGHT)}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user