Theme Builder: accordion panels across the full config surface, vertical-tab nav, reflective gallery

This commit is contained in:
2026-06-21 17:14:58 +03:00
parent 318a0919c6
commit d76a7a5014
24 changed files with 2420 additions and 845 deletions
+218 -78
View File
@@ -1,25 +1,40 @@
/**
* 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.
* Structured controls over the base `legend` config: placement, the title's and
* labels' colour and size, the symbol keys, the continuous gradient bar, and an
* optional box behind the legend. Legend type (size) lives here rather than the
* Type panel so every legend property a brand tunes sits together.
*/
import type { JsonObject } from '@core/spec-config';
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
import {
asNumber,
asString,
type ConfigPath,
countSet,
enumValue,
getConfigValue,
} from '@core/theme-controls';
import { useConfigSetter } from '../stores/CustomThemeStore';
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
import type { SelectControlOption } from './SelectControl';
import styles from './ThemeFields.module.css';
const ORIENT: ConfigPath = ['legend', 'orient'];
const DIRECTION: ConfigPath = ['legend', 'direction'];
const COLUMNS: ConfigPath = ['legend', 'columns'];
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 SYMBOL_TYPE: ConfigPath = ['legend', 'symbolType'];
const GRADIENT_LENGTH: ConfigPath = ['legend', 'gradientLength'];
const GRADIENT_THICKNESS: ConfigPath = ['legend', 'gradientThickness'];
const FILL_COLOR: ConfigPath = ['legend', 'fillColor'];
const STROKE_COLOR: ConfigPath = ['legend', 'strokeColor'];
const CORNER_RADIUS: ConfigPath = ['legend', 'cornerRadius'];
const PADDING: ConfigPath = ['legend', 'padding'];
const TEXT_GREY = '#888888';
@@ -35,80 +50,205 @@ const orientOptions: SelectControlOption<Orient>[] = [
{ 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) : '';
};
type Direction = '' | 'horizontal' | 'vertical';
const directionOptions: SelectControlOption<Direction>[] = [
{ value: '', label: 'Theme default' },
{ value: 'vertical', label: 'Vertical' },
{ value: 'horizontal', label: 'Horizontal' },
];
// Discrete-legend key shapes (continuous legends render a gradient bar instead).
type SymbolType = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'stroke';
const symbolTypeOptions: SelectControlOption<SymbolType>[] = [
{ value: '', label: 'Theme default' },
{ value: 'circle', label: 'Circle' },
{ value: 'square', label: 'Square' },
{ value: 'cross', label: 'Cross' },
{ value: 'diamond', label: 'Diamond' },
{ value: 'triangle-up', label: 'Triangle' },
{ value: 'stroke', label: 'Line' },
];
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));
const sections: AccordionSection[] = [
{
id: 'placement',
title: 'Placement',
hint: 'Where the legend sits relative to the plot.',
badge: countSet(config, [ORIENT, DIRECTION, COLUMNS]),
children: (
<>
<SelectRow
id="legend-orient"
label="Position"
options={orientOptions}
value={enumValue(getConfigValue(config, ORIENT), orientOptions)}
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
/>
<SelectRow
id="legend-direction"
label="Direction"
options={directionOptions}
value={enumValue(getConfigValue(config, DIRECTION), directionOptions)}
onSelect={(d) => set(DIRECTION, d === '' ? undefined : d)}
/>
<NumberRow
label="Columns"
name="Legend columns"
value={asNumber(getConfigValue(config, COLUMNS))}
min={0}
onChange={(n) => set(COLUMNS, n)}
/>
</>
),
},
{
id: 'title',
title: 'Title',
badge: countSet(config, [TITLE_COLOR, TITLE_SIZE]),
children: (
<>
<ColorRow
label="Color"
name="Legend title color"
value={asString(getConfigValue(config, TITLE_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(TITLE_COLOR, hex)}
onClear={() => set(TITLE_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend title size"
value={asNumber(getConfigValue(config, TITLE_SIZE))}
min={0}
unit="px"
onChange={(n) => set(TITLE_SIZE, n)}
/>
</>
),
},
{
id: 'labels',
title: 'Labels',
badge: countSet(config, [LABEL_COLOR, LABEL_SIZE]),
children: (
<>
<ColorRow
label="Color"
name="Legend label color"
value={asString(getConfigValue(config, LABEL_COLOR))}
fallback={TEXT_GREY}
onChange={(hex) => set(LABEL_COLOR, hex)}
onClear={() => set(LABEL_COLOR, undefined)}
/>
<NumberRow
label="Size"
name="Legend label size"
value={asNumber(getConfigValue(config, LABEL_SIZE))}
min={0}
unit="px"
onChange={(n) => set(LABEL_SIZE, n)}
/>
</>
),
},
{
id: 'symbols',
title: 'Symbols',
hint: 'The colored keys beside each label.',
badge: countSet(config, [SYMBOL_TYPE, SYMBOL_SIZE]),
children: (
<>
<SelectRow
id="legend-symbol-type"
label="Shape"
name="Legend symbol shape"
options={symbolTypeOptions}
value={enumValue(getConfigValue(config, SYMBOL_TYPE), symbolTypeOptions)}
onSelect={(t) => set(SYMBOL_TYPE, t === '' ? undefined : t)}
/>
<NumberRow
label="Symbol size"
value={asNumber(getConfigValue(config, SYMBOL_SIZE))}
min={0}
unit="px²"
onChange={(n) => set(SYMBOL_SIZE, n)}
/>
</>
),
},
{
id: 'gradient',
title: 'Gradient',
hint: 'The continuous color bar (quantitative legends).',
badge: countSet(config, [GRADIENT_LENGTH, GRADIENT_THICKNESS]),
children: (
<>
<NumberRow
label="Length"
name="Gradient length"
value={asNumber(getConfigValue(config, GRADIENT_LENGTH))}
min={0}
unit="px"
onChange={(n) => set(GRADIENT_LENGTH, n)}
/>
<NumberRow
label="Thickness"
name="Gradient thickness"
value={asNumber(getConfigValue(config, GRADIENT_THICKNESS))}
min={0}
unit="px"
onChange={(n) => set(GRADIENT_THICKNESS, n)}
/>
</>
),
},
{
id: 'box',
title: 'Box',
hint: 'An optional filled/bordered panel behind the legend.',
badge: countSet(config, [FILL_COLOR, STROKE_COLOR, CORNER_RADIUS, PADDING]),
children: (
<>
<ColorRow
label="Fill"
name="Legend fill"
value={asString(getConfigValue(config, FILL_COLOR))}
fallback="#ffffff"
onChange={(hex) => set(FILL_COLOR, hex)}
onClear={() => set(FILL_COLOR, undefined)}
/>
<ColorRow
label="Border"
name="Legend border"
value={asString(getConfigValue(config, STROKE_COLOR))}
fallback="#cccccc"
onChange={(hex) => set(STROKE_COLOR, hex)}
onClear={() => set(STROKE_COLOR, undefined)}
/>
<NumberRow
label="Corner radius"
name="Legend corner radius"
value={asNumber(getConfigValue(config, CORNER_RADIUS))}
min={0}
unit="px"
onChange={(n) => set(CORNER_RADIUS, n)}
/>
<NumberRow
label="Padding"
name="Legend padding"
value={asNumber(getConfigValue(config, PADDING))}
min={0}
unit="px"
onChange={(n) => set(PADDING, n)}
/>
</>
),
},
];
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>
);
return <Accordion sections={sections} idPrefix="legend" />;
}