ColorField: shared color-input primitive, Theme Builder + Chart Builder migration

This commit is contained in:
2026-06-14 11:02:20 +03:00
parent 236b1c8a37
commit df6a0b3845
8 changed files with 240 additions and 114 deletions
+5
View File
@@ -179,6 +179,11 @@ UI by swapping one set of values. Borrowed from Carbon's layering model:
buttons are **`IconButton`** (24px `sm` exists solely for controls nested buttons are **`IconButton`** (24px `sm` exists solely for controls nested
_inside_ a 32px control — a search field's clear, a toast's dismiss); writing _inside_ a 32px control — a search field's clear, a toast's dismiss); writing
`height:` on a new ad-hoc button is the code smell. `height:` on a new ad-hoc button is the code smell.
- **Color inputs go through `ColorField`** — the one home for the native
`type="color"` chrome reset and the optional paired hex field (read/copy/retype
a value). A bare `<input type="color">` is the code smell; the Theme Builder
swatches (`hex`) and the Chart Builder constant-colour binding (`size="sm"`,
inside a pill) share it. Per-context removal is the caller's, not the field's.
- **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled - **Buttons:** square, via the `Button` primitive. Variants: **primary** (filled
`--accent`), **secondary** (1px `--border-strong`, `--bg` fill — `--accent`), **secondary** (1px `--border-strong`, `--bg` fill —
so a bordered control on a gray panel goes white, never a darker gray), **ghost** (text-only, borderless; a transparent so a bordered control on a gray panel goes white, never a darker gray), **ghost** (text-only, borderless; a transparent
@@ -921,14 +921,9 @@
outline-offset: -2px; outline-offset: -2px;
} }
/* Sizing + chrome come from ColorField (size="sm"); only the pill margin is local. */
.constColor { .constColor {
width: 28px;
height: 22px;
margin: 0 var(--space-2); margin: 0 var(--space-2);
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
cursor: pointer;
} }
.constNumber { .constNumber {
+5 -4
View File
@@ -76,6 +76,7 @@ import {
} from '../stores/ChartBuilderStore'; } from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button'; import { Button } from './Button';
import { ColorField } from './ColorField';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl'; import { SelectControl } from './SelectControl';
import { Icon } from './Icon'; import { Icon } from './Icon';
@@ -310,12 +311,12 @@ function ChannelPill({
<div className={`${styles.pill} ${styles.pillConst}`}> <div className={`${styles.pill} ${styles.pillConst}`}>
<span className={styles.pillTag}>value</span> <span className={styles.pillTag}>value</span>
{isColor ? ( {isColor ? (
<input <ColorField
type="color" size="sm"
className={styles.constColor} className={styles.constColor}
aria-label={`${CHANNEL_LABELS[channel]} constant colour`} label={`${CHANNEL_LABELS[channel]} constant colour`}
value={typeof mapping.value === 'string' ? mapping.value : '#000000'} value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
onChange={(e) => setChannelConstant(channel, e.target.value)} onChange={(v) => setChannelConstant(channel, v)}
/> />
) : ( ) : (
<input <input
+4 -49
View File
@@ -45,65 +45,20 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* One swatch: color picker + copyable hex field, with a hover remove. */ /* One swatch: a ColorField (swatch + hex) with its remove. */
.swatchUnit { .swatchUnit {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-1);
}
.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;
} }
/* The remove is quiet until the row is hovered or holds focus. */
.swatchRemove { .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; opacity: 0;
transition: opacity var(--dur-fast) var(--ease); transition: opacity var(--dur-fast) var(--ease);
} }
.swatchUnit:hover .swatchRemove, .swatchUnit:hover .swatchRemove,
.swatchRemove:focus-visible { .swatchUnit:focus-within .swatchRemove {
opacity: 1; opacity: 1;
} }
+12 -55
View File
@@ -16,7 +16,7 @@
* can be read, copied, and retyped anywhere. * can be read, copied, and retyped anywhere.
*/ */
import { useState, type ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { import {
type ConfigPath, type ConfigPath,
@@ -27,6 +27,9 @@ import {
} from '@core/theme-controls'; } from '@core/theme-controls';
import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { Button } from './Button'; import { Button } from './Button';
import { ColorField } from './ColorField';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { SelectControl, type SelectControlOption } from './SelectControl'; import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './ColorControls.module.css'; import styles from './ColorControls.module.css';
@@ -46,17 +49,10 @@ const DEFAULT_DIVERGING = 'redblue';
/** Stops a materialized gradient starts with (continuous schemes are sampled). */ /** Stops a materialized gradient starts with (continuous schemes are sampled). */
const GRADIENT_STOPS = 7; const GRADIENT_STOPS = 7;
const isHex = (c: string): boolean => /^#[0-9a-f]{6}$/i.test(c);
const asArray = (v: unknown): string[] | null => const asArray = (v: unknown): string[] | null =>
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null; Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
const asString = (v: unknown): string | null => (typeof v === '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 => const gradientCss = (colors: string[]): string =>
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent'; colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
@@ -91,7 +87,7 @@ const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('divergin
preview: gradientBar(schemeColors(s.name, 12)), preview: gradientBar(schemeColors(s.name, 12)),
})); }));
/** A color picker paired with a copyable, editable hex field; optional remove. */ /** A ColorField swatch with a hover/focus-revealed remove, for editable lists. */
function SwatchRow({ function SwatchRow({
color, color,
label, label,
@@ -103,57 +99,18 @@ function SwatchRow({
onChange: (hex: string) => void; onChange: (hex: string) => void;
onRemove?: () => 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 ( return (
<span className={styles.swatchUnit}> <span className={styles.swatchUnit}>
<input <ColorField value={color} label={label} onChange={onChange} hex />
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 && ( {onRemove && (
// TODO: icon-only control as a raw <button> with a literal "×" rather <IconButton
// than IconButton + <Icon name="close"/> (the controlled glyph vocabulary size="sm"
// arch 09 §5 uses everywhere else). The corner badge is 16px — below label={`Remove ${label}`}
// 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} className={styles.swatchRemove}
aria-label={`Remove ${label}`}
title="Remove"
onClick={onRemove} onClick={onRemove}
> >
× <Icon name="close" />
</button> </IconButton>
)} )}
</span> </span>
); );
@@ -265,7 +222,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
</p> </p>
<div className={styles.row}> <div className={styles.row}>
<SwatchRow <SwatchRow
color={markColor && isHex(markColor) ? markColor : VEGA_DEFAULT_MARK} color={markColor || VEGA_DEFAULT_MARK}
label="Default mark color" label="Default mark color"
onChange={(hex) => set(MARK_COLOR, hex)} onChange={(hex) => set(MARK_COLOR, hex)}
/> />
+43
View File
@@ -0,0 +1,43 @@
/* ColorField — token-styled native color swatch (+ optional hex field). */
.field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
/* Native color input, sized to a swatch and stripped of its OS chrome. */
.swatch {
width: var(--control-height);
height: var(--control-height);
padding: 0;
border: var(--border-width) solid var(--border-strong);
background: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
/* Compact: sits inside the 24px-tall Chart Builder constant pill. */
.sm {
width: 28px;
height: 22px;
}
.swatch::-webkit-color-swatch-wrapper {
padding: 2px;
}
.swatch::-webkit-color-swatch {
border: none;
}
.swatch::-moz-color-swatch {
border: none;
}
.hex {
width: 8ch;
height: var(--control-height);
padding: 0 var(--space-2);
font-family: var(--font-mono);
font-size: 12px;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* ColorField — the shared color-input primitive (swatch + optional hex field).
* Covers the render shapes and the hex-entry commit/normalize behavior; the
* Theme Builder and Chart Builder integrations are exercised in their own tests.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { ColorField } from './ColorField';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
/** Drive an input's value through the native setter so React's onChange fires. */
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 swatch = () => container.querySelector<HTMLInputElement>('input[type="color"]')!;
const hexField = () => container.querySelector<HTMLInputElement>('input[type="text"]');
describe('ColorField', () => {
test('renders just the swatch by default (no hex field)', () => {
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={() => {}} />));
expect(swatch().getAttribute('aria-label')).toBe('Fill');
expect(swatch().value).toBe('#112233');
expect(hexField()).toBeNull();
});
test('falls back to #000000 in the picker for a non-hex value', () => {
act(() => root.render(<ColorField value="steelblue" label="Fill" onChange={() => {}} />));
expect(swatch().value).toBe('#000000');
});
test('the picker reports its raw value on change', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} />));
act(() => setNativeValue(swatch(), '#ff0000'));
expect(onChange).toHaveBeenCalledWith('#ff0000');
});
test('hex mode commits a valid hex and normalizes an unprefixed one', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} hex />));
const hex = hexField()!;
expect(hex.getAttribute('aria-label')).toBe('Fill hex value');
act(() => setNativeValue(hex, '#abcdef'));
expect(onChange).toHaveBeenLastCalledWith('#abcdef');
act(() => setNativeValue(hex, 'ABCDEF'));
expect(onChange).toHaveBeenLastCalledWith('#abcdef');
});
test('hex mode does not commit an incomplete value', () => {
const onChange = vi.fn();
act(() => root.render(<ColorField value="#112233" label="Fill" onChange={onChange} hex />));
act(() => setNativeValue(hexField()!, '#abc'));
expect(onChange).not.toHaveBeenCalled();
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* ColorField — the app's color input (arch 09 §5, component primitives).
*
* A token-styled native color swatch bound to a hex value, optionally paired
* with a copyable/editable hex text field. The single place the `type="color"`
* chrome reset and the hex-entry behavior live: the Theme Builder swatches
* (`hex`) and the Chart Builder constant-colour binding (`size="sm"`, no hex)
* both use it. Removal is the caller's concern — it differs per context (a
* per-swatch button vs. the pill's own remove) — so this renders only the
* input(s).
*/
import { useState } from 'react';
import styles from './ColorField.module.css';
/** True for a `#rrggbb` string — what the native picker accepts. */
function isHexColor(c: string): boolean {
return /^#[0-9a-f]{6}$/i.test(c);
}
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
function normalizeHexColor(raw: string): string | null {
const v = raw.trim().replace(/^#/, '');
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
}
export interface ColorFieldProps {
value: string;
onChange: (hex: string) => void;
/** Accessible name for the swatch; the hex field derives its name from it. */
label: string;
/** Also render the editable, copyable hex text field beside the swatch. */
hex?: boolean;
/** `md` (32px, default) for forms; `sm` (compact) to sit inside a pill. */
size?: 'md' | 'sm';
/** Extra class merged onto the swatch (layout only — e.g. a pill's margin). */
className?: string;
}
export function ColorField({
value,
onChange,
label,
hex,
size = 'md',
className,
}: ColorFieldProps) {
// (hex field) Local text so a partially-typed hex isn't rejected mid-keystroke;
// commits on a valid value, reverts to the committed color on blur. Resync to a
// value changed from the outside (picker, materialize, scheme) but not to this
// field's own commit — the render-time adjustment is React's alternative to a
// sync effect.
const [text, setText] = useState(value);
const [synced, setSynced] = useState(value);
if (hex && value !== synced) {
setSynced(value);
if (normalizeHexColor(text) !== value) setText(value);
}
const swatch = (
<input
type="color"
className={`${styles.swatch} ${size === 'sm' ? styles.sm : ''} ${className ?? ''}`}
aria-label={label}
value={isHexColor(value) ? value : '#000000'}
onChange={(e) => onChange(e.target.value)}
/>
);
if (!hex) return swatch;
return (
<span className={styles.field}>
{swatch}
<input
type="text"
className={styles.hex}
aria-label={`${label} hex value`}
spellCheck={false}
value={text}
onChange={(e) => {
setText(e.target.value);
const norm = normalizeHexColor(e.target.value);
if (norm) onChange(norm);
}}
onBlur={() => setText(value)}
/>
</span>
);
}