mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
ColorField: shared color-input primitive, Theme Builder + Chart Builder migration
This commit is contained in:
@@ -921,14 +921,9 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Sizing + chrome come from ColorField (size="sm"); only the pill margin is local. */
|
||||
.constColor {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
margin: 0 var(--space-2);
|
||||
padding: 0;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.constNumber {
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
} from '../stores/ChartBuilderStore';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { IconButton } from './IconButton';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import { Icon } from './Icon';
|
||||
@@ -310,12 +311,12 @@ function ChannelPill({
|
||||
<div className={`${styles.pill} ${styles.pillConst}`}>
|
||||
<span className={styles.pillTag}>value</span>
|
||||
{isColor ? (
|
||||
<input
|
||||
type="color"
|
||||
<ColorField
|
||||
size="sm"
|
||||
className={styles.constColor}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} constant colour`}
|
||||
label={`${CHANNEL_LABELS[channel]} constant colour`}
|
||||
value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
|
||||
onChange={(e) => setChannelConstant(channel, e.target.value)}
|
||||
onChange={(v) => setChannelConstant(channel, v)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
|
||||
@@ -45,65 +45,20 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* One swatch: color picker + copyable hex field, with a hover remove. */
|
||||
/* One swatch: a ColorField (swatch + hex) with its 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;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* The remove is quiet until the row is hovered or holds focus. */
|
||||
.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 {
|
||||
.swatchUnit:focus-within .swatchRemove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* 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 ConfigPath,
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
} from '@core/theme-controls';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ColorControls.module.css';
|
||||
|
||||
@@ -46,17 +49,10 @@ 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';
|
||||
|
||||
@@ -91,7 +87,7 @@ const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('divergin
|
||||
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({
|
||||
color,
|
||||
label,
|
||||
@@ -103,57 +99,18 @@ function SwatchRow({
|
||||
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)}
|
||||
/>
|
||||
<ColorField value={color} label={label} onChange={onChange} hex />
|
||||
{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"
|
||||
<IconButton
|
||||
size="sm"
|
||||
label={`Remove ${label}`}
|
||||
className={styles.swatchRemove}
|
||||
aria-label={`Remove ${label}`}
|
||||
title="Remove"
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Icon name="close" />
|
||||
</IconButton>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -265,7 +222,7 @@ export function ColorControls({ config }: { config: JsonObject }) {
|
||||
</p>
|
||||
<div className={styles.row}>
|
||||
<SwatchRow
|
||||
color={markColor && isHex(markColor) ? markColor : VEGA_DEFAULT_MARK}
|
||||
color={markColor || VEGA_DEFAULT_MARK}
|
||||
label="Default mark color"
|
||||
onChange={(hex) => set(MARK_COLOR, hex)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user