);
}
/** One compact "label + number input(+suffix)" field — shared by the RGB/HSL rows below. */
function NumField( {
label,
ariaLabel,
value,
min,
max,
suffix,
inputRef,
onCommit,
}: {
label: string;
ariaLabel: string;
value: number;
min: number;
max: number;
suffix?: string;
inputRef: React.RefObject< HTMLInputElement >;
onCommit: ( n: number ) => void;
} ) {
return (
{ label }
{
const n = Number( ( e.target as HTMLInputElement ).value );
if ( ! Number.isNaN( n ) ) {
onCommit( clampNumber( n, min, max ) );
}
} }
onBlur={ () => {
if ( inputRef.current ) {
inputRef.current.value = String( value );
}
} }
/>
{ suffix && { suffix } }
);
}
/** Curated quick-pick row inside every color popover — neutrals, the panel's own accent, and a
* handful of common brand/UI colours, so a common choice never requires touching the wheel. */
const PRESET_SWATCHES = [
'#ffffff', '#f8fafc', '#e5e7eb', '#9ca3af', '#4b5563', '#1f2433', '#111111', '#000000',
'#7545bb', '#3b82f6', '#0ea5e9', '#16a34a', '#f59e0b', '#f97316', '#dc2626', '#ec4899',
];
/**
* The full solid-colour editing surface — wheel, HEX/RGB/HSL switch, opacity, eyedropper and
* quick-pick presets. Used both as a plain colour popover's body AND, unchanged, as a gradient
* stop's own editor — the exact reason a gradient stop no longer feels like a different, lesser
* control than every other colour field in the panel.
*/
function SolidColorFields( { label, value, onChange }: { label: string; value: string; onChange: ( color: string ) => void } ) {
const parsed = parseColor( value );
const popHexRef = React.useRef< HTMLInputElement >( null );
const popAlphaNumRef = React.useRef< HTMLInputElement >( null );
const [ format, setFormat ] = React.useState< 'hex' | 'rgb' | 'hsl' >( 'hex' );
const hasEyeDropper = typeof ( window as any ).EyeDropper !== 'undefined';
useSyncedInput( popHexRef, parsed.hex.toUpperCase() );
useSyncedInput( popAlphaNumRef, String( parsed.alpha ) );
const rgb = hexToRgb( parsed.hex );
const hsl = rgbToHsl( rgb.r, rgb.g, rgb.b );
const rRef = React.useRef< HTMLInputElement >( null );
const gRef = React.useRef< HTMLInputElement >( null );
const bRef = React.useRef< HTMLInputElement >( null );
const hRef = React.useRef< HTMLInputElement >( null );
const sRef = React.useRef< HTMLInputElement >( null );
const lRef = React.useRef< HTMLInputElement >( null );
useSyncedInput( rRef, String( rgb.r ) );
useSyncedInput( gRef, String( rgb.g ) );
useSyncedInput( bRef, String( rgb.b ) );
useSyncedInput( hRef, String( hsl.h ) );
useSyncedInput( sRef, String( hsl.s ) );
useSyncedInput( lRef, String( hsl.l ) );
const commitHex = ( hex: string ) => onChange( composeColor( hex, parsed.alpha ) );
const commitAlpha = ( alpha: number ) => onChange( composeColor( parsed.hex, clampNumber( alpha, 0, 100 ) ) );
const commitHex8 = ( hex8: string ) => {
const p = parseColor( hex8 );
onChange( composeColor( p.hex, p.alpha ) );
};
const commitRgb = ( r: number, g: number, b: number ) => commitHex( rgbToHex( r, g, b ) );
const commitHsl = ( h: number, s: number, l: number ) => {
const c = hslToRgb( h, s, l );
commitHex( rgbToHex( c.r, c.g, c.b ) );
};
const onHex = ( e: React.FormEvent< HTMLInputElement > ) => {
let t = ( e.target as HTMLInputElement ).value.trim();
if ( t && t[ 0 ] !== '#' ) {
t = '#' + t;
}
if ( /^#[0-9a-f]{3}$/i.test( t ) ) {
t = '#' + t.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' );
}
if ( /^#[0-9a-f]{6}$/i.test( t ) ) {
commitHex( t.toLowerCase() );
}
};
/* Chromium's EyeDropper API — sample any pixel on screen straight into this field.
* Feature-detected: the tool button simply doesn't render in browsers without it. */
const pickFromScreen = async () => {
try {
const ED = ( window as any ).EyeDropper;
const result = await new ED().open();
if ( result?.sRGBHex ) {
commitHex( result.sRGBHex.toLowerCase() );
}
} catch {
// User cancelled (Escape) — nothing to do.
}
};
return (
<>
{ ( [ 'hex', 'rgb', 'hsl' ] as const ).map( ( f ) => (
) ) }
{ hasEyeDropper && (
) }
{ format === 'hex' && (
{ __( 'Hex', 'everest-forms' ) }
{
if ( popHexRef.current ) {
popHexRef.current.value = parseColor( value ).hex.toUpperCase();
}
} }
/>
) }
{ format === 'rgb' && (
<>
commitRgb( n, rgb.g, rgb.b ) }
/>
commitRgb( rgb.r, n, rgb.b ) }
/>
commitRgb( rgb.r, rgb.g, n ) }
/>
>
) }
{ format === 'hsl' && (
<>
commitHsl( n, hsl.s, hsl.l ) }
/>
commitHsl( hsl.h, n, hsl.l ) }
/>
commitHsl( hsl.h, hsl.s, n ) }
/>
>
) }
{ __( 'Opacity', 'everest-forms' ) }
{
const n = Number( ( e.target as HTMLInputElement ).value );
if ( ! Number.isNaN( n ) ) {
commitAlpha( n );
}
} }
onBlur={ () => {
if ( popAlphaNumRef.current ) {
popAlphaNumRef.current.value = String( parseColor( value ).alpha );
}
} }
/>
%
{ PRESET_SWATCHES.map( ( c ) => (
>
);
}
/**
* The gradient editing surface — a draggable-stop bar (click empty space to add a stop, drag a
* marker to reposition it, arrow keys to nudge), the selected stop's full {@see SolidColorFields}
* editor, and a compass + numeric angle control.
*/
function GradientEditor( { label, value, onChange }: { label: string; value: string; onChange: ( v: string ) => void } ) {
const grad = parseGradient( value );
const [ selected, setSelected ] = React.useState( 0 );
const sel = Math.min( selected, grad.stops.length - 1 );
const barRef = React.useRef< HTMLDivElement >( null );
const angleRef = React.useRef< HTMLInputElement >( null );
useSyncedInput( angleRef, String( Math.round( grad.angle ) ) );
// `commit` always re-sorts by position (composeGradient) before serializing, so a stop's
// array index can shift on the very next render — e.g. adding a stop in the middle, or
// dragging one past a neighbour. `gradRef`/`selRef` track the latest committed state and
// selection synchronously (a plain closure would go stale mid-drag, before React re-renders),
// so every add/move recomputes where the stop being edited LANDS after that re-sort and keeps
// `selected` — and therefore the caption and the colour editor below — pointing at the same
// stop the user is actually holding, not whichever one happens to land at the old index.
const gradRef = React.useRef( grad );
gradRef.current = grad;
const selRef = React.useRef( sel );
selRef.current = sel;
const commit = ( angle: number, stops: GradStop[] ) => onChange( composeGradient( angle, stops ) );
/** Where a stop at `pos` will rank once the full set is re-sorted ascending (ties settle
* after any existing equal-position stop, matching a stable ascending sort). */
const rankOf = ( others: GradStop[], pos: number ) => others.filter( ( s ) => s.pos <= pos ).length;
const setStopColor = ( i: number, color: string ) => {
const current = gradRef.current;
commit( current.angle, current.stops.map( ( s, idx ) => ( idx === i ? { ...s, color } : s ) ) );
};
const setStopPos = ( i: number, pos: number ) => {
const current = gradRef.current;
const clamped = clampNumber( pos, 0, 100 );
const stops = current.stops.map( ( s, idx ) => ( idx === i ? { ...s, pos: clamped } : s ) );
const rank = rankOf( stops.filter( ( _, idx ) => idx !== i ), clamped );
selRef.current = rank;
setSelected( rank );
commit( current.angle, stops );
};
const addStop = ( pos: number ) => {
const current = gradRef.current;
const sorted = [ ...current.stops ].sort( ( a, b ) => a.pos - b.pos );
let color = sorted[ 0 ].color;
for ( let i = 0; i < sorted.length - 1; i++ ) {
if ( pos >= sorted[ i ].pos ) {
color = sorted[ i ].color;
}
}
const rank = rankOf( sorted, pos );
selRef.current = rank;
setSelected( rank );
commit( current.angle, [ ...current.stops, { color, pos } ] );
};
const removeStop = ( i: number ) => {
const current = gradRef.current;
if ( current.stops.length <= 2 ) {
return;
}
selRef.current = 0;
setSelected( 0 );
commit( current.angle, current.stops.filter( ( _, idx ) => idx !== i ) );
};
const posFromClientX = ( clientX: number ) => {
const el = barRef.current;
if ( ! el ) {
return 0;
}
const r = el.getBoundingClientRect();
return clampNumber( ( ( clientX - r.left ) / r.width ) * 100, 0, 100 );
};
// A drag that ends over the bar itself (not back on the marker) fires the bar's own click
// right after — per the DOM spec, when mousedown/mouseup targets differ, click bubbles to
// their common ancestor, which here is the bar — so a completed drag would otherwise always
// add a spurious extra stop right where the marker was just dropped.
const justDraggedRef = React.useRef( false );
const startDrag = ( i: number ) => ( e: React.MouseEvent ) => {
e.preventDefault();
e.stopPropagation();
selRef.current = i;
setSelected( i );
let moved = false;
const move = ( ev: MouseEvent ) => {
moved = true;
setStopPos( selRef.current, posFromClientX( ev.clientX ) );
};
const up = () => {
window.removeEventListener( 'mousemove', move );
window.removeEventListener( 'mouseup', up );
justDraggedRef.current = moved;
};
window.addEventListener( 'mousemove', move );
window.addEventListener( 'mouseup', up );
};
const onBarClick = ( e: React.MouseEvent ) => {
if ( justDraggedRef.current ) {
justDraggedRef.current = false;
return;
}
addStop( posFromClientX( e.clientX ) );
};
const barCss = composeGradient( 90, grad.stops );
return (
{ grad.stops.map( ( s, i ) => (
{ __( 'Click the bar to add a stop, drag a marker to move it.', 'everest-forms' ) }
);
}
/**
* Swatch + hex box that opens a popover with a full saturation/hue/alpha picker and an
* "Opacity %" field — the one color-editing surface every part of the panel should share
* (element controls, "Your Palette" slots, anywhere else a raw color needs editing).
* Store/token-agnostic on purpose: the caller decides what `value` means and what `onChange`
* does with the recomposed color string.
*/
export function ColorPickerField( {
label,
value,
onChange,
gradientable,
}: {
label: string;
value: string;
onChange: ( color: string ) => void;
/** Whether this token's CSS rule uses the `background` shorthand — see {@see Token.gradientable}. */
gradientable?: boolean;
} ) {
const isGrad = gradientable && isGradientValue( value );
const parsed = parseColor( isGrad ? '' : value );
const hexRef = React.useRef< HTMLInputElement >( null );
const rootRef = React.useRef< HTMLDivElement >( null );
const swatchRef = React.useRef< HTMLButtonElement >( null );
const popRef = React.useRef< HTMLDivElement >( null );
const [ invalid, setInvalid ] = React.useState( false );
const [ pickerOpen, setPickerOpen ] = React.useState( false );
const [ pos, setPos ] = React.useState< { left: number; top: number } | null >( null );
useSyncedInput( hexRef, parsed.hex.toUpperCase() );
useDismiss( pickerOpen, rootRef, () => setPickerOpen( false ), popRef );
// Gradient mode (only reachable when `gradientable`). Deriving a sane default FROM the
// current solid colour (rather than an arbitrary stock gradient) means switching modes
// never jars — the preview always starts from what was already on screen.
const grad = isGrad
? parseGradient( value )
: { angle: 135, stops: [ { color: composeColor( parsed.hex, parsed.alpha ), pos: 0 }, { color: shade( parsed.hex, -0.35 ), pos: 100 } ] };
// Portaled + position:fixed (see below) so the popover can never be clipped by a scrolling
// ancestor (the panel sidebar, a palette's own scrollable row list, etc.) — same escape-hatch
// HoverTip already uses for its own tooltip.
const updatePos = React.useCallback( () => {
const trigger = swatchRef.current;
if ( ! trigger ) {
return;
}
const r = trigger.getBoundingClientRect();
const width = popRef.current?.offsetWidth || 264;
const height = popRef.current?.offsetHeight || 0;
const margin = 8;
// WordPress's own fixed admin bar (32px on desktop, 46px under ~600px wide) sits above
// everything at the very top of the page — clamping to 8px from the viewport edge (as
// this used to) can still land the popover right underneath it, close enough that only
// a sliver of the popover's own heading peeks out below the bar on a short screen where
// the flip-above branch kicks in. Keep clear of it outright instead of guessing its exact
// height: WP renders it with `#wpadminbar`.
const adminBar = document.getElementById( 'wpadminbar' );
const topBound = margin + ( adminBar ? adminBar.getBoundingClientRect().bottom : 0 );
let left = r.left;
left = Math.min( Math.max( margin, left ), window.innerWidth - width - margin );
// Pick whichever side actually has more room, rather than "below unless it doesn't fit,
// then blindly above" — the old rule could flip to a side with EVEN LESS room on a short
// screen. Then hard-clamp both edges so a popover taller than either side still lands
// fully on-screen (its own max-height + overflow-y:auto, see style.scss, takes it from
// there if it's taller than the whole viewport).
const roomBelow = window.innerHeight - r.bottom - margin;
const roomAbove = r.top - topBound;
const top = ! height || height <= roomBelow || roomBelow >= roomAbove
? r.bottom + 6
: r.top - height - 6;
setPos( { left, top: clampNumber( top, topBound, Math.max( topBound, window.innerHeight - height - margin ) ) } );
}, [] );
React.useLayoutEffect( () => {
if ( pickerOpen ) {
updatePos();
} else {
setPos( null );
}
}, [ pickerOpen, updatePos ] );
React.useEffect( () => {
if ( ! pickerOpen ) {
return;
}
window.addEventListener( 'scroll', updatePos, true );
window.addEventListener( 'resize', updatePos );
return () => {
window.removeEventListener( 'scroll', updatePos, true );
window.removeEventListener( 'resize', updatePos );
};
}, [ pickerOpen, updatePos ] );
const commitHex = ( hex: string ) => onChange( composeColor( hex, parsed.alpha ) );
const switchToGradient = () => onChange( composeGradient( grad.angle, grad.stops ) );
const switchToSolid = () => {
const p = parseColor( grad.stops[ 0 ].color );
onChange( composeColor( p.hex, p.alpha ) );
};
const onHex = ( e: React.FormEvent< HTMLInputElement > ) => {
let t = ( e.target as HTMLInputElement ).value.trim();
if ( t && t[ 0 ] !== '#' ) {
t = '#' + t;
}
if ( /^#[0-9a-f]{3}$/i.test( t ) ) {
t = '#' + t.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' );
}
if ( /^#[0-9a-f]{6}$/i.test( t ) ) {
setInvalid( false );
commitHex( t.toLowerCase() );
} else {
setInvalid( true );
}
};
return (
);
}
function ColorControl( props: ControlProps ) {
const { token, store } = props;
const value = String( store.resolve( token.key ) );
return (
store.setTokenValue( token.key, color, true ) }
gradientable={ !! token.gradientable }
/>
);
}
const SIDE_LABELS = [ 'Top', 'Right', 'Bottom', 'Left' ] as const;
const CORNER_LABELS = [ 'Top-left', 'Top-right', 'Bottom-right', 'Bottom-left' ] as const;
const SIDE_ABBR = [ 'T', 'R', 'B', 'L' ];
const CORNER_ABBR = [ 'TL', 'TR', 'BR', 'BL' ];
const BOX_KEYS: Array< keyof BoxValue > = [ 'top', 'right', 'bottom', 'left' ];
/** Are all four box sides currently equal? Seeds the initial "link sides" state. */
function allSidesEqual( v: BoxValue ): boolean {
return v.top === v.right && v.right === v.bottom && v.bottom === v.left;
}
function Box4Control( props: ControlProps ) {
const { token, store } = props;
const value = clone( store.resolve( token.key ) ) as BoxValue;
const cellRefs = [
React.useRef< HTMLInputElement >( null ),
React.useRef< HTMLInputElement >( null ),
React.useRef< HTMLInputElement >( null ),
React.useRef< HTMLInputElement >( null ),
];
const [ linked, setLinked ] = React.useState( () => allSidesEqual( value ) );
const min = token.min ?? ( token.key.indexOf( 'margin' ) !== -1 ? -1000 : 0 );
const max = token.max ?? 1000;
const abbr = token.corners ? CORNER_ABBR : SIDE_ABBR;
const labels = token.corners ? CORNER_LABELS : SIDE_LABELS;
const unit = token.units && token.units.length ? value.unit || token.units[ 0 ] : null;
React.useEffect( () => {
cellRefs.forEach( ( ref, i ) => {
const el = ref.current;
if ( el && el.ownerDocument.activeElement !== el ) {
el.value = String( value[ BOX_KEYS[ i ] ] ?? 0 );
}
} );
} );
const commit = ( index: number, raw: number ) => {
if ( Number.isNaN( raw ) ) {
return;
}
const n = clampNumber( raw, min, max );
const next = clone( store.resolve( token.key ) ) as BoxValue;
if ( linked ) {
BOX_KEYS.forEach( ( k ) => ( next[ k ] = n ) );
cellRefs.forEach( ( r ) => r.current && ( r.current.value = String( n ) ) );
} else {
next[ BOX_KEYS[ index ] ] = n;
}
store.setTokenValue( token.key, next, true );
};
const toggleUnit = () => {
if ( ! token.units || token.units.length < 2 ) {
return;
}
const next = clone( store.resolve( token.key ) ) as BoxValue;
const cur = next.unit || token.units[ 0 ];
next.unit = token.units[ ( token.units.indexOf( cur ) + 1 ) % token.units.length ];
store.setTokenValue( token.key, next, false );
};
return (
px : undefined }>
{ BOX_KEYS.map( ( _k, i ) => (
commit( i, parseInt( ( e.target as HTMLInputElement ).value, 10 ) ) }
onBlur={ ( e ) => {
( e.target as HTMLInputElement ).value = String(
( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0
);
} }
onKeyDown={ ( e ) => {
if ( e.key !== 'ArrowUp' && e.key !== 'ArrowDown' ) {
return;
}
e.preventDefault();
const cur = ( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0;
const step = e.shiftKey ? 10 : 1;
commit( i, Number( cur ) + ( e.key === 'ArrowUp' ? step : -step ) );
( e.currentTarget as HTMLInputElement ).value = String(
( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0
);
} }
/>
) ) }
{ BOX_KEYS.map( ( _k, i ) => (
{ abbr[ i ] }
) ) }
setLinked( ! linked ) }
>
{ unit && (
{ unit }
) }
);
}
/** Custom dropdown select — a styled replacement for the native `