Colour fields: hex validation, brand chips, pick tools, save colour, inline contrast prop; gradient parse accepts brand refs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
150c84065b
commit
c34690d08b
6 files changed
+371
-5
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSiteDesign } from '../../../state/SiteDesignContext';
|
||||
import { addSwatch, findSwatchByHex, normalizeHex, MAX_SWATCHES } from '../../../utils/swatches';
|
||||
import { matchSiteColor, pickFromCanvas, pickFromScreen, screenPickKind } from '../../../utils/color-pick';
|
||||
import { resolveTokenValue } from '../../../utils/tokens';
|
||||
import { ImagePickModal } from './ImagePickModal';
|
||||
|
||||
export interface ColorPickToolsProps {
|
||||
/** The field's current value (hex, token ref or brand ref). */
|
||||
value: string;
|
||||
/** Receives the picked value: a site/brand reference (when `allowRefs` and one matches) or the raw hex. */
|
||||
onPick: (value: string) => void;
|
||||
/**
|
||||
* Whether a pick may become a `var(--wsb-*)` reference. False for the Site
|
||||
* Design colours themselves: they are the token SOURCE, a reference there
|
||||
* would be a cycle.
|
||||
*/
|
||||
allowRefs?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "get this colour from somewhere" row shown under every colour
|
||||
* field: pick from the page, from the screen (Chromium only), from an image,
|
||||
* and save the current colour to the site's saved colours.
|
||||
*/
|
||||
export const ColorPickTools: React.FC<ColorPickToolsProps> = ({ value, onPick, allowRefs = true }) => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [imageOpen, setImageOpen] = useState(false);
|
||||
const swatches = Array.isArray(design.swatches) ? design.swatches : [];
|
||||
|
||||
const literal = normalizeHex(resolveTokenValue(value || '', design));
|
||||
const alreadySaved = !!literal && !!findSwatchByHex(swatches, literal);
|
||||
const canSave = !!literal && !alreadySaved && swatches.length < MAX_SWATCHES;
|
||||
|
||||
const applyHex = (hex: string) => {
|
||||
const ref = allowRefs ? matchSiteColor(hex, design)?.ref : undefined;
|
||||
onPick(ref ?? hex);
|
||||
};
|
||||
|
||||
const run = async (kind: 'page' | 'screen') => {
|
||||
setMsg('');
|
||||
setBusy(true);
|
||||
try {
|
||||
if (kind === 'page') {
|
||||
const r = await pickFromCanvas({ design });
|
||||
if (r.ok) {
|
||||
const cand = r.candidates.find((c) => c.hex === r.hex && c.match);
|
||||
onPick(allowRefs && cand?.match ? cand.match.ref : r.hex);
|
||||
} else if (r.reason !== 'cancelled') setMsg(r.message);
|
||||
} else {
|
||||
const r = await pickFromScreen();
|
||||
if (r.ok) applyHex(r.hex);
|
||||
else if (r.reason !== 'cancelled') setMsg(r.message);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="color-pick-tools" style={{ marginTop: 6 }}>
|
||||
<div className="color-pick-tools__row" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('page')}>Pick from page</button>
|
||||
{screenPickKind() === 'eyedropper' && (
|
||||
<button type="button" className="color-tool-btn" disabled={busy} onClick={() => run('screen')}>Pick from screen</button>
|
||||
)}
|
||||
<button type="button" className="color-tool-btn" onClick={() => setImageOpen(true)}>Pick from image</button>
|
||||
<button
|
||||
type="button"
|
||||
className="color-tool-btn"
|
||||
disabled={!canSave}
|
||||
title={alreadySaved ? 'This colour is already saved' : 'Add this colour to the site\'s saved colours'}
|
||||
onClick={() => { if (literal && canSave) updateDesign({ swatches: addSwatch(swatches, literal, '') }); }}
|
||||
>
|
||||
Save colour
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div role="status" className="color-pick-tools__msg" style={{ fontSize: 11, color: 'var(--color-warning, #d97706)', marginTop: 4 }}>{msg}</div>}
|
||||
{imageOpen && (
|
||||
<ImagePickModal
|
||||
onPick={(hex) => { applyHex(hex); setImageOpen(false); }}
|
||||
onClose={() => setImageOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ColorPickerField, ColorSwatchGrid, GradientSwatchGrid } from './shared';
|
||||
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../../state/SiteDesignContext';
|
||||
|
||||
const pickFromCanvas = vi.fn();
|
||||
const pickFromScreen = vi.fn();
|
||||
let screenKind: 'eyedropper' | 'none' = 'none';
|
||||
vi.mock('../../../utils/color-pick', async (orig) => {
|
||||
const real = await orig<typeof import('../../../utils/color-pick')>();
|
||||
return { ...real, pickFromCanvas: (...a: unknown[]) => pickFromCanvas(...a), pickFromScreen: (...a: unknown[]) => pickFromScreen(...a), screenPickKind: () => screenKind };
|
||||
});
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root;
|
||||
const ctx: { current: SiteDesignContextValue | null } = { current: null };
|
||||
const Probe: React.FC = () => { ctx.current = useSiteDesign(); return null; };
|
||||
function render(ui: React.ReactElement, swatches?: unknown[]) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => { root = createRoot(container!); root.render(<SiteDesignProvider><Probe />{ui}</SiteDesignProvider>); });
|
||||
if (swatches) act(() => { ctx.current!.updateDesign({ swatches: swatches as never }); });
|
||||
}
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
pickFromCanvas.mockReset(); pickFromScreen.mockReset(); screenKind = 'none';
|
||||
if (!container) return;
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
const hexBox = () => container!.querySelector<HTMLInputElement>('input[type="text"]')!;
|
||||
const chip = () => container!.querySelector('.token-chip');
|
||||
const btn = (text: string) => Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.trim() === text) as HTMLButtonElement | undefined;
|
||||
function type(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
|
||||
const BRAND = { id: 'b1', label: 'Logo blue', hex: '#15202c', brand: true, slot: 2 };
|
||||
|
||||
describe('hex validation', () => {
|
||||
test('invalid text is not committed and shows a red hint; valid text clears it and commits normalised', () => {
|
||||
vi.useFakeTimers();
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
act(() => { type(hexBox(), '#12'); });
|
||||
act(() => { vi.advanceTimersByTime(400); });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
const err = container!.querySelector('[role="alert"]')!;
|
||||
expect(err.textContent).toMatch(/hex/i);
|
||||
act(() => { type(hexBox(), 'ABCDEF'); });
|
||||
act(() => { vi.advanceTimersByTime(400); });
|
||||
expect(onChange).toHaveBeenCalledWith('#abcdef');
|
||||
expect(container!.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
test('ColorSwatchGrid validates too', () => {
|
||||
vi.useFakeTimers();
|
||||
const onSelect = vi.fn();
|
||||
render(<ColorSwatchGrid colors={[]} activeValue="#000000" onSelect={onSelect} />);
|
||||
act(() => { type(hexBox(), 'zzz'); });
|
||||
act(() => { vi.advanceTimersByTime(400); });
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(container!.querySelector('[role="alert"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('brand references', () => {
|
||||
test('chip shows the swatch label, or "Brand N" when unlabelled; the hex box shows the live hex', () => {
|
||||
render(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [BRAND]);
|
||||
expect(chip()!.textContent).toBe('Logo blue');
|
||||
expect(hexBox().value).toBe('#15202c');
|
||||
});
|
||||
test('unlabelled brand colour -> Brand N', () => {
|
||||
render(<ColorPickerField label="C" value="var(--wsb-brand-2)" onChange={() => {}} />, [{ ...BRAND, label: '' }]);
|
||||
expect(chip()!.textContent).toBe('Brand 2');
|
||||
});
|
||||
test('gradient custom builder parses a brand ref', () => {
|
||||
render(<GradientSwatchGrid activeValue="linear-gradient(135deg, var(--wsb-brand-2) 0%, #764ba2 100%)" onSelect={() => {}} />, [BRAND]);
|
||||
act(() => { btn('Custom Gradient')?.click() ?? Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); });
|
||||
const boxes = Array.from(container!.querySelectorAll<HTMLInputElement>('input[type="text"]')).map((i) => i.value);
|
||||
expect(boxes).toContain('#15202c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('save colour', () => {
|
||||
test('adds the field literal hex to the saved colours, then disables', () => {
|
||||
render(<ColorPickerField label="C" value="#abcdef" onChange={() => {}} />);
|
||||
const b = btn('Save colour')!;
|
||||
expect(b.disabled).toBe(false);
|
||||
act(() => { b.click(); });
|
||||
expect(ctx.current!.design.swatches.map((s) => s.hex)).toEqual(['#abcdef']);
|
||||
expect(btn('Save colour')!.disabled).toBe(true);
|
||||
});
|
||||
test('a token value saves its resolved hex; a gradient cannot be saved', () => {
|
||||
render(<ColorPickerField label="C" value="var(--wsb-primary)" onChange={() => {}} />);
|
||||
act(() => { btn('Save colour')!.click(); });
|
||||
expect(ctx.current!.design.swatches[0].hex).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick tools', () => {
|
||||
test('Pick from screen only exists where the screen eyedropper does', () => {
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
expect(btn('Pick from page')).toBeDefined();
|
||||
expect(btn('Pick from image')).toBeDefined();
|
||||
expect(btn('Pick from screen')).toBeUndefined();
|
||||
act(() => root.unmount()); container!.remove(); container = null;
|
||||
screenKind = 'eyedropper';
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
expect(btn('Pick from screen')).toBeDefined();
|
||||
});
|
||||
test('Pick from page uses the site/brand reference when the pick matches one', async () => {
|
||||
const onChange = vi.fn();
|
||||
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#15202c', source: 'canvas', candidates: [{ kind: 'background', hex: '#15202c', match: { kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' } }] });
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
await act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(onChange).toHaveBeenCalledWith('var(--wsb-brand-2)');
|
||||
});
|
||||
test('without a match the raw hex is used', async () => {
|
||||
const onChange = vi.fn();
|
||||
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#010203', source: 'canvas', candidates: [{ kind: 'background', hex: '#010203' }] });
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
await act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(onChange).toHaveBeenCalledWith('#010203');
|
||||
});
|
||||
test('Site Design token fields (showTokens=false) never store a token ref from a pick', async () => {
|
||||
const onChange = vi.fn();
|
||||
pickFromCanvas.mockResolvedValue({ ok: true, hex: '#3b82f6', source: 'canvas', candidates: [{ kind: 'background', hex: '#3b82f6', match: { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' } }] });
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={onChange} showTokens={false} />);
|
||||
await act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(onChange).toHaveBeenCalledWith('#3b82f6');
|
||||
});
|
||||
test('a failed pick shows the message; a cancelled one is silent', async () => {
|
||||
pickFromCanvas.mockResolvedValue({ ok: false, reason: 'error', message: 'Could not read it.' });
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} />);
|
||||
await act(async () => { btn('Pick from page')!.click(); });
|
||||
await flush();
|
||||
expect(container!.textContent).toContain('Could not read it.');
|
||||
});
|
||||
test('Pick from screen applies the eyedropper hex', async () => {
|
||||
screenKind = 'eyedropper';
|
||||
const onChange = vi.fn();
|
||||
pickFromScreen.mockResolvedValue({ ok: true, hex: '#a1b2c3', source: 'eyedropper' });
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
|
||||
await act(async () => { btn('Pick from screen')!.click(); });
|
||||
await flush();
|
||||
expect(onChange).toHaveBeenCalledWith('#a1b2c3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline contrast', () => {
|
||||
test('ColorPickerField renders a ContrastNote when contrastWith is given and the pair fails', () => {
|
||||
render(<ColorPickerField label="C" value="#eeeeee" onChange={() => {}} contrastWith="#ffffff" />);
|
||||
expect(container!.querySelector('.contrast-note--fail')).not.toBeNull();
|
||||
});
|
||||
test('passing pair and gradient background show nothing / unchecked only', () => {
|
||||
render(<ColorPickerField label="C" value="#000000" onChange={() => {}} contrastWith="#ffffff" />);
|
||||
expect(container!.querySelector('.contrast-note')).toBeNull();
|
||||
});
|
||||
test('the suggestion applies through onChange', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ColorPickerField label="C" value="#eeeeee" onChange={onChange} contrastWith="#ffffff" />);
|
||||
act(() => { (container!.querySelector('.contrast-note__use') as HTMLButtonElement).click(); });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@ import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/S
|
||||
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens';
|
||||
import { isLightColor } from '../../../utils/color';
|
||||
import { TokenSwatchRow } from './TokenSwatchRow';
|
||||
import { ColorPickTools } from './ColorPickTools';
|
||||
import { ContrastNote } from './ContrastNote';
|
||||
import { parseColorInput } from '../../../utils/color-input';
|
||||
import { brandSlotOf } from '../../../utils/swatches';
|
||||
import { useDevice } from '../../../state/DeviceContext';
|
||||
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
|
||||
import type { DeviceMode } from '../../../types';
|
||||
@@ -34,7 +38,12 @@ export function useResolvedColor(value: string | undefined): { resolved: string;
|
||||
const v = value || '';
|
||||
const resolved = resolveTokenValue(v, design);
|
||||
const key = tokenKeyOf(v);
|
||||
const tokenLabel = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
|
||||
let tokenLabel: string | null = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
|
||||
const slot = brandSlotOf(v);
|
||||
if (slot !== null) {
|
||||
const sw = (Array.isArray(design.swatches) ? design.swatches : []).find((s) => s.brand && s.slot === slot);
|
||||
tokenLabel = sw?.label.trim() || `Brand ${slot}`;
|
||||
}
|
||||
const nativeValue = /^#[0-9a-fA-F]{6}$/.test(resolved) ? resolved : '#000000';
|
||||
return { resolved, nativeValue, tokenLabel };
|
||||
}
|
||||
@@ -69,6 +78,16 @@ export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => (
|
||||
<label className="guided-section-label">{children}</label>
|
||||
);
|
||||
|
||||
/** Hex text box state shared by ColorPickerField / ColorSwatchGrid: only valid colour text is committed; invalid text keeps showing with a red hint. */
|
||||
function useColorText(resolved: string, commit: (v: string) => void) {
|
||||
const guarded = (v: string) => { const r = parseColorInput(v); if (r.ok) commit(r.value); };
|
||||
const hex = useDebouncedCommit(resolved, guarded);
|
||||
const invalid = !parseColorInput(hex.local).ok;
|
||||
return { hex, invalid };
|
||||
}
|
||||
const HexError: React.FC<{ show: boolean }> = ({ show }) =>
|
||||
show ? <div role="alert" className="color-hex-error">Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).</div> : null;
|
||||
|
||||
interface ColorSwatchGridProps {
|
||||
colors: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
@@ -82,7 +101,7 @@ interface ColorSwatchGridProps {
|
||||
}
|
||||
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
|
||||
const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue);
|
||||
const hex = useDebouncedCommit(resolved, onSelect);
|
||||
const { hex, invalid } = useColorText(resolved, onSelect);
|
||||
return (
|
||||
<div>
|
||||
{showTokens && <TokenSwatchRow value={activeValue} onSelect={onSelect} />}
|
||||
@@ -104,6 +123,8 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
|
||||
/>
|
||||
<TokenChip label={tokenLabel} />
|
||||
</div>
|
||||
<HexError show={invalid} />
|
||||
<ColorPickTools value={activeValue || ''} onPick={onSelect} allowRefs={showTokens} />
|
||||
<div className="preset-grid">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
@@ -168,7 +189,7 @@ interface GradientSwatchGridProps {
|
||||
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
|
||||
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
|
||||
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))/);
|
||||
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z0-9-]+\))/);
|
||||
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
|
||||
return { angle: 135, from: '#667eea', to: '#764ba2' };
|
||||
}
|
||||
@@ -363,10 +384,18 @@ interface ColorPickerFieldProps {
|
||||
/** Show the site-colour (token) row. Default true; Site Design's own
|
||||
* colours pass false (they are the token SOURCE and must store hex). */
|
||||
showTokens?: boolean;
|
||||
/** The colour this one is read against (text on a background, or the reverse). Renders an inline contrast hint. */
|
||||
contrastWith?: string;
|
||||
/** Large text / UI part: the 3:1 threshold instead of 4.5:1. */
|
||||
contrastLarge?: boolean;
|
||||
/** Prefix for the hint ("Link on background"). */
|
||||
contrastLabel?: string;
|
||||
/** Which side of the pair this field is. Default 'fg' (this colour is the text). */
|
||||
contrastRole?: 'fg' | 'bg';
|
||||
}
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true }) => {
|
||||
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => {
|
||||
const { resolved, nativeValue, tokenLabel } = useResolvedColor(value);
|
||||
const hex = useDebouncedCommit(resolved, onChange);
|
||||
const { hex, invalid } = useColorText(resolved, onChange);
|
||||
return (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
@@ -389,6 +418,17 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
|
||||
/>
|
||||
<TokenChip label={tokenLabel} />
|
||||
</div>
|
||||
<HexError show={invalid} />
|
||||
<ColorPickTools value={value} onPick={onChange} allowRefs={showTokens} />
|
||||
{contrastWith && (
|
||||
<ContrastNote
|
||||
fg={contrastRole === 'fg' ? value : contrastWith}
|
||||
bg={contrastRole === 'fg' ? contrastWith : value}
|
||||
large={contrastLarge}
|
||||
label={contrastLabel}
|
||||
onUseSuggestion={contrastRole === 'fg' ? onChange : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3390,3 +3390,18 @@ body {
|
||||
.token-swatch--live.active {
|
||||
box-shadow: 0 0 0 2px var(--color-bg-surface, #18181b), 0 0 0 4px var(--color-accent);
|
||||
}
|
||||
|
||||
/* Colour pick tools (under every colour field) */
|
||||
.color-tool-btn {
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
padding: 3px 7px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.color-tool-btn:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-text-muted); }
|
||||
.color-tool-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.color-hex-error { font-size: 11px; color: #ef4444; margin-top: 3px; }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { parseColorInput } from './color-input';
|
||||
|
||||
describe('parseColorInput', () => {
|
||||
test('hex with or without #, any case, 3/4/6/8 digits, normalised', () => {
|
||||
expect(parseColorInput('#ABCDEF')).toEqual({ ok: true, value: '#abcdef' });
|
||||
expect(parseColorInput('abc')).toEqual({ ok: true, value: '#abc' });
|
||||
expect(parseColorInput(' 12345678 ')).toEqual({ ok: true, value: '#12345678' });
|
||||
expect(parseColorInput('#abcd')).toEqual({ ok: true, value: '#abcd' });
|
||||
});
|
||||
test('rejects partial / garbage hex', () => {
|
||||
for (const bad of ['#12', '#12345', '#1234567', '#gggggg', 'red!', '#', '12 34', 'url(x)', 'var(--x)']) {
|
||||
expect(parseColorInput(bad).ok, bad).toBe(false);
|
||||
}
|
||||
});
|
||||
test('keeps site token and brand refs, transparent, rgb()/hsl(); empty clears', () => {
|
||||
expect(parseColorInput('var(--wsb-primary)')).toEqual({ ok: true, value: 'var(--wsb-primary)' });
|
||||
expect(parseColorInput('var(--wsb-brand-2)')).toEqual({ ok: true, value: 'var(--wsb-brand-2)' });
|
||||
expect(parseColorInput('Transparent')).toEqual({ ok: true, value: 'transparent' });
|
||||
expect(parseColorInput('rgba(0, 0, 0, 0.5)')).toEqual({ ok: true, value: 'rgba(0, 0, 0, 0.5)' });
|
||||
expect(parseColorInput('hsl(120 40% 50%)')).toEqual({ ok: true, value: 'hsl(120 40% 50%)' });
|
||||
expect(parseColorInput('')).toEqual({ ok: true, value: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { normalizeHex } from './swatches';
|
||||
import { isTokenRef } from './tokens';
|
||||
|
||||
/**
|
||||
* What a colour text box may commit. Hex (with or without '#', normalised to
|
||||
* lowercase '#...'), site/brand token refs, `transparent`, simple rgb()/hsl()
|
||||
* functions, or empty (a deliberate clear). Anything else is not committed.
|
||||
*/
|
||||
export type ColorInputResult = { ok: true; value: string } | { ok: false };
|
||||
|
||||
const FN_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[-\d.%\s,/]+\)$/i;
|
||||
|
||||
export function parseColorInput(text: string): ColorInputResult {
|
||||
const t = String(text ?? '').trim();
|
||||
if (t === '') return { ok: true, value: '' };
|
||||
const isRef: boolean = isTokenRef(t);
|
||||
if (isRef) return { ok: true, value: t };
|
||||
if (t.toLowerCase() === 'transparent') return { ok: true, value: 'transparent' };
|
||||
if (FN_RE.test(t)) return { ok: true, value: t };
|
||||
const h = normalizeHex(t);
|
||||
return h ? { ok: true, value: h } : { ok: false };
|
||||
}
|
||||
Reference in new issue
Block a user