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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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