diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 5569031..48faa0a 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -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 _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. +- **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 `` 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 `--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 diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css index ea168a4..8b87c79 100644 --- a/src/app/components/ChartBuilderModal.module.css +++ b/src/app/components/ChartBuilderModal.module.css @@ -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 { diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx index 0d60430..539001a 100644 --- a/src/app/components/ChartBuilderModal.tsx +++ b/src/app/components/ChartBuilderModal.tsx @@ -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({
value {isColor ? ( - setChannelConstant(channel, e.target.value)} + onChange={(v) => setChannelConstant(channel, v)} /> ) : ( /^#[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[] = 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 ( - onChange(e.target.value)} - /> - { - setText(e.target.value); - const norm = normalizeHex(e.target.value); - if (norm) onChange(norm); - }} - onBlur={() => setText(color)} - /> + {onRemove && ( - // TODO: icon-only control as a raw + + )} ); @@ -265,7 +222,7 @@ export function ColorControls({ config }: { config: JsonObject }) {

set(MARK_COLOR, hex)} /> diff --git a/src/app/components/ColorField.module.css b/src/app/components/ColorField.module.css new file mode 100644 index 0000000..d812514 --- /dev/null +++ b/src/app/components/ColorField.module.css @@ -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; +} diff --git a/src/app/components/ColorField.test.tsx b/src/app/components/ColorField.test.tsx new file mode 100644 index 0000000..03ba63b --- /dev/null +++ b/src/app/components/ColorField.test.tsx @@ -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('input[type="color"]')!; +const hexField = () => container.querySelector('input[type="text"]'); + +describe('ColorField', () => { + test('renders just the swatch by default (no hex field)', () => { + act(() => root.render( {}} />)); + 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( {}} />)); + expect(swatch().value).toBe('#000000'); + }); + + test('the picker reports its raw value on change', () => { + const onChange = vi.fn(); + act(() => root.render()); + 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()); + 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()); + act(() => setNativeValue(hexField()!, '#abc')); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/ColorField.tsx b/src/app/components/ColorField.tsx new file mode 100644 index 0000000..f8af5aa --- /dev/null +++ b/src/app/components/ColorField.tsx @@ -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 = ( + onChange(e.target.value)} + /> + ); + + if (!hex) return swatch; + + return ( + + {swatch} + { + setText(e.target.value); + const norm = normalizeHexColor(e.target.value); + if (norm) onChange(norm); + }} + onBlur={() => setText(value)} + /> + + ); +}