/** * Style Customizer v2 — one component per schema control type (slider, color, box4, select, * align, fontstyle, media, toggle), each reading/writing through the store. */ import React from 'react'; import { createPortal } from 'react-dom'; import { HexAlphaColorPicker } from 'react-colorful'; import { ALIGN_ICONS, DEVICE_ICONS, DEVICE_LABELS, FONTSTYLE_BUTTONS, clone, } from './constants'; import { HoverTip } from './HoverTip'; import { StyleStore } from './store'; import { BoxValue, FontStyleValue, Token } from './types'; const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s ); /** Same portal target HoverTip uses — inside the panel root (so scoped `#everest-forms-panel-style` * CSS still applies) but outside any scrollable ancestor that would otherwise clip a popover. */ function popoverHost(): HTMLElement { return document.getElementById( 'everest-forms-panel-style' ) || document.body; } interface ControlProps { token: Token; store: StyleStore; dimmed?: boolean; } /* --------------------------------------------------------------------- * * Small helpers * --------------------------------------------------------------------- */ /** Keep an uncontrolled input's DOM value in sync with state, but never while it's focused. */ function useSyncedInput< T extends HTMLInputElement >( ref: React.RefObject< T >, display: string ) { React.useEffect( () => { const el = ref.current; if ( el && el.ownerDocument.activeElement !== el ) { el.value = display; } } ); } const clampNumber = ( n: number, min: number, max: number ) => Math.min( max, Math.max( min, n ) ); const isFloatStep = ( token: Token ) => !! ( token.step && token.step < 1 ); const roundToStep = ( n: number, token: Token ) => ( isFloatStep( token ) ? Math.round( n * 10 ) / 10 : Math.round( n ) ); /** Parse any Sanitizer-accepted colour (#rgb, #rrggbb, #rrggbbaa, rgb()/rgba()) into a 6-digit hex + 0-100 alpha. */ function parseColor( value: string ): { hex: string; alpha: number } { const v = value.trim(); const rgbaMatch = v.match( /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)$/i ); if ( rgbaMatch ) { const toHex = ( n: string ) => clampNumber( Math.round( Number( n ) ), 0, 255 ).toString( 16 ).padStart( 2, '0' ); const hex = '#' + toHex( rgbaMatch[ 1 ] ) + toHex( rgbaMatch[ 2 ] ) + toHex( rgbaMatch[ 3 ] ); const rawAlpha = rgbaMatch[ 4 ]; const alpha = rawAlpha === undefined ? 100 : clampNumber( Math.round( rawAlpha.endsWith( '%' ) ? parseFloat( rawAlpha ) : parseFloat( rawAlpha ) * 100 ), 0, 100 ); return { hex, alpha }; } if ( /^#[0-9a-f]{3}$/i.test( v ) ) { return { hex: '#' + v.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' ), alpha: 100 }; } if ( /^#[0-9a-f]{6}$/i.test( v ) ) { return { hex: v.toLowerCase(), alpha: 100 }; } if ( /^#[0-9a-f]{8}$/i.test( v ) ) { const alpha = clampNumber( Math.round( ( parseInt( v.slice( 7, 9 ), 16 ) / 255 ) * 100 ), 0, 100 ); return { hex: v.slice( 0, 7 ).toLowerCase(), alpha }; } return { hex: '#000000', alpha: 100 }; } /** Recompose a 6-digit hex + 0-100 alpha back into the stored value (plain hex when fully opaque). */ function composeColor( hex: string, alpha: number ): string { if ( alpha >= 100 ) { return hex; } const alphaHex = clampNumber( Math.round( ( alpha / 100 ) * 255 ), 0, 255 ).toString( 16 ).padStart( 2, '0' ); return hex + alphaHex; } /** Always-8-digit form, for react-colorful's `HexAlphaColorPicker` (its value/onChange contract is always `#rrggbbaa`). */ function toHex8( hex: string, alpha: number ): string { const alphaHex = clampNumber( Math.round( ( alpha / 100 ) * 255 ), 0, 255 ).toString( 16 ).padStart( 2, '0' ); return hex + alphaHex; } /** 6-digit hex -> 0-255 RGB triple. */ function hexToRgb( hex: string ): { r: number; g: number; b: number } { const n = parseInt( hex.slice( 1 ), 16 ) || 0; return { r: ( n >> 16 ) & 255, g: ( n >> 8 ) & 255, b: n & 255 }; } /** 0-255 RGB triple -> 6-digit hex. */ function rgbToHex( r: number, g: number, b: number ): string { const c = ( n: number ) => clampNumber( Math.round( n ), 0, 255 ).toString( 16 ).padStart( 2, '0' ); return '#' + c( r ) + c( g ) + c( b ); } /** 0-255 RGB triple -> {h: 0-360, s/l: 0-100}. */ function rgbToHsl( r: number, g: number, b: number ): { h: number; s: number; l: number } { r /= 255; g /= 255; b /= 255; const max = Math.max( r, g, b ); const min = Math.min( r, g, b ); const l = ( max + min ) / 2; const d = max - min; let h = 0; let s = 0; if ( d !== 0 ) { s = d / ( 1 - Math.abs( 2 * l - 1 ) ); switch ( max ) { case r: h = 60 * ( ( ( g - b ) / d ) % 6 ); break; case g: h = 60 * ( ( b - r ) / d + 2 ); break; default: h = 60 * ( ( r - g ) / d + 4 ); } } if ( h < 0 ) { h += 360; } return { h: Math.round( h ), s: Math.round( s * 100 ), l: Math.round( l * 100 ) }; } /** {h: 0-360, s/l: 0-100} -> 0-255 RGB triple. */ function hslToRgb( h: number, s: number, l: number ): { r: number; g: number; b: number } { h = ( ( h % 360 ) + 360 ) % 360; const sf = clampNumber( s, 0, 100 ) / 100; const lf = clampNumber( l, 0, 100 ) / 100; const c = ( 1 - Math.abs( 2 * lf - 1 ) ) * sf; const x = c * ( 1 - Math.abs( ( ( h / 60 ) % 2 ) - 1 ) ); const m = lf - c / 2; let r = 0; let g = 0; let b = 0; if ( h < 60 ) { r = c; g = x; b = 0; } else if ( h < 120 ) { r = x; g = c; b = 0; } else if ( h < 180 ) { r = 0; g = c; b = x; } else if ( h < 240 ) { r = 0; g = x; b = c; } else if ( h < 300 ) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return { r: ( r + m ) * 255, g: ( g + m ) * 255, b: ( b + m ) * 255 }; } /** Lighten (positive) or darken (negative) a hex colour toward white/black by `amt` (0-1). */ function shade( hex: string, amt: number ): string { const { r, g, b } = hexToRgb( hex ); const t = amt >= 0 ? 255 : 0; const k = Math.min( 1, Math.abs( amt ) ); return rgbToHex( r + ( t - r ) * k, g + ( t - g ) * k, b + ( t - b ) * k ); } /** A single colour stop on the gradient bar — `color` is any Sanitizer-legal solid value * (hex, 8-digit hex, or rgba()), `pos` is its 0-100 position along the bar. */ interface GradStop { color: string; pos: number; } /** Whether a token value is one of our gradients — see {@see composeGradient}. */ function isGradientValue( value: string ): boolean { return /^(linear|radial)-gradient\(/i.test( String( value || '' ).trim() ); } /** Split on top-level commas only — skips commas nested inside an `rgba()` stop. Mirrors * {@see Sanitizer::split_top_level_commas} on the PHP side. */ function splitTopLevelCommas( str: string ): string[] { const parts: string[] = []; let depth = 0; let cur = ''; for ( let i = 0; i < str.length; i++ ) { const ch = str[ i ]; if ( ch === '(' ) { depth++; } else if ( ch === ')' ) { depth--; } if ( ch === ',' && depth === 0 ) { parts.push( cur ); cur = ''; continue; } cur += ch; } parts.push( cur ); return parts; } /** A fresh, sane 2-stop gradient — used both as the very first gradient a field ever gets and * as a fallback if a hand-authored value doesn't parse. */ function defaultGradient(): { angle: number; stops: GradStop[] } { return { angle: 135, stops: [ { color: '#3366cc', pos: 0 }, { color: shade( '#3366cc', -0.35 ), pos: 100 } ] }; } /** Parse any of our (or hand-authored) `linear-gradient()` values into an editable angle + stop list. */ function parseGradient( value: string ): { angle: number; stops: GradStop[] } { const m = String( value ).trim().match( /^linear-gradient\(\s*(-?\d+(?:\.\d+)?)deg\s*,\s*(.+)\)$/i ); if ( ! m ) { return defaultGradient(); } const angle = Number( m[ 1 ] ); const parts = splitTopLevelCommas( m[ 2 ] ); const stops: GradStop[] = []; parts.forEach( ( part, i ) => { const mm = part.trim().match( /^(#[0-9a-f]{3,8}|rgba?\([^()]*\))\s*(-?\d+(?:\.\d+)?%)?$/i ); if ( ! mm ) { return; } const p = parseColor( mm[ 1 ] ); const pos = mm[ 2 ] !== undefined ? clampNumber( parseFloat( mm[ 2 ] ), 0, 100 ) : ( parts.length > 1 ? Math.round( ( i / ( parts.length - 1 ) ) * 100 ) : 0 ); stops.push( { color: composeColor( p.hex, p.alpha ), pos } ); } ); return stops.length >= 2 ? { angle, stops } : defaultGradient(); } /** Stops are always serialized in ascending position order — CSS itself would force this * anyway (a gradient's stops must monotonically increase), so keeping the string that way * avoids the browser silently re-clamping something our own editor didn't expect. */ function composeGradient( angle: number, stops: GradStop[] ): string { const sorted = [ ...stops ].sort( ( a, b ) => a.pos - b.pos ); return `linear-gradient(${ Math.round( angle ) }deg, ${ sorted.map( ( s ) => `${ s.color } ${ Math.round( s.pos ) }%` ).join( ', ' ) })`; } /** The 8 compass directions a CSS gradient angle commonly points — 0deg is "up", clockwise from there. */ const ANGLE_COMPASS: Array< { deg: number; area: string } | null > = [ { deg: 315, area: 'nw' }, { deg: 0, area: 'n' }, { deg: 45, area: 'ne' }, { deg: 270, area: 'w' }, null, { deg: 90, area: 'e' }, { deg: 225, area: 'sw' }, { deg: 180, area: 's' }, { deg: 135, area: 'se' }, ]; /** One-click preset directions, laid out like a compass — faster and far more intuitive than * typing a degree value for the common cases, with the numeric field alongside for the rest. */ function AngleCompass( { angle, onChange }: { angle: number; onChange: ( deg: number ) => void } ) { const rounded = Math.round( angle ); return (
{ ANGLE_COMPASS.map( ( cell, i ) => cell ? ( ) : (
); } /** 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' ) }

{ __( 'Stop', 'everest-forms' ) } { sel + 1 } · { Math.round( grad.stops[ sel ].pos ) }% { grad.stops.length > 2 && ( ) }
setStopColor( sel, c ) } />
commit( deg, grad.stops ) } /> commit( n, grad.stops ) } />
); } function Svg( { inner, className }: { inner: string; className?: string } ) { return ( ); } /** Shared chevron-down glyph for every custom dropdown trigger (selects). */ function ChevronDownIcon() { return ( ); } /** Shared selected-item checkmark for every custom dropdown list. */ function CheckIcon() { return ( ); } /** Close a dropdown on outside click / Escape — shared by every custom select below. * `extraRef` covers content that lives outside `rootRef` in the DOM (e.g. a portaled popover), * so a click inside IT doesn't count as "outside" either. */ function useDismiss( open: boolean, rootRef: React.RefObject< HTMLElement >, onDismiss: () => void, extraRef?: React.RefObject< HTMLElement > ) { React.useEffect( () => { if ( ! open ) { return; } const onDown = ( e: MouseEvent ) => { const target = e.target as Node; if ( rootRef.current && rootRef.current.contains( target ) ) { return; } if ( extraRef?.current && extraRef.current.contains( target ) ) { return; } onDismiss(); }; const onKey = ( e: KeyboardEvent ) => { if ( e.key === 'Escape' ) { onDismiss(); } }; document.addEventListener( 'mousedown', onDown ); document.addEventListener( 'keydown', onKey ); return () => { document.removeEventListener( 'mousedown', onDown ); document.removeEventListener( 'keydown', onKey ); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ open ] ); } /* --------------------------------------------------------------------- * * Shared label row + shell * --------------------------------------------------------------------- */ /** Body of the device-badge hover tooltip: explains the per-device state of a responsive token. */ function ResponsiveTip( { token, store, override }: { token: Token; store: StyleStore; override: boolean } ) { return ( <>
{ token.label } — { __( 'responsive control', 'everest-forms' ) }
{ store.device === 'desktop' ? ( __( 'You’re editing the Desktop base value. Switch to tablet or mobile to set a per-device override.', 'everest-forms' ) ) : ( <> { __( 'Editing', 'everest-forms' ) } { DEVICE_LABELS[ store.device ] } —{ ' ' } { override ? __( 'this device has its own value. Use the reset button to remove it.', 'everest-forms' ) : __( 'currently inheriting the Desktop value.', 'everest-forms' ) } ) }
); } function ControlShell( { token, store, right, inlineRight, children, dimmed, }: ControlProps & { right?: React.ReactNode; inlineRight?: React.ReactNode; children: React.ReactNode } ) { const changed = store.isChanged( token.key ); const inherited = token.responsive && store.device !== 'desktop' && ! store.isOverride( token.key ); const override = store.isOverride( token.key ); const classNames = [ 'ctrl' ]; if ( changed ) { classNames.push( 'changed' ); } if ( inherited ) { classNames.push( 'inherited' ); } if ( dimmed ) { classNames.push( 'shared-dim' ); } return (
{ token.responsive && store.device !== 'desktop' && ( } > ) } { inlineRight } { right }
{ children }
); } /* --------------------------------------------------------------------- * * Controls * --------------------------------------------------------------------- */ function SliderControl( props: ControlProps ) { const { token, store } = props; const value = Number( store.resolve( token.key ) ); const unit = token.unit !== undefined ? token.unit : 'px'; const min = token.min ?? 0; const max = token.max ?? 300; const numRef = React.useRef< HTMLInputElement >( null ); useSyncedInput( numRef, String( value ) ); const commit = ( raw: number, gesture: boolean ) => { if ( Number.isNaN( raw ) ) { return; } store.setTokenValue( token.key, roundToStep( clampNumber( raw, min, max ), token ), gesture ); }; // The native thumb travels inset by half its own width from each track edge (matches CSS). const THUMB = 15; const pct = max > min ? clampNumber( ( ( value - min ) / ( max - min ) ) * 100, 0, 100 ) : 0; return (
commit( Number( e.target.value ), true ) } />
{ const n = isFloatStep( token ) ? parseFloat( ( e.target as HTMLInputElement ).value ) : parseInt( ( e.target as HTMLInputElement ).value, 10 ); commit( n, true ); } } onBlur={ () => { if ( numRef.current ) { numRef.current.value = String( store.resolve( token.key ) ); } } } onKeyDown={ ( e ) => { if ( e.key !== 'ArrowUp' && e.key !== 'ArrowDown' ) { return; } e.preventDefault(); const base = token.step || 1; const step = e.shiftKey ? base * 10 : base; commit( Number( store.resolve( token.key ) ) + ( e.key === 'ArrowUp' ? step : -step ), true ); ( e.currentTarget as HTMLInputElement ).value = String( store.resolve( token.key ) ); } } /> { unit && { unit } }
); } /** * 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 (
) } { isGrad ? ( ) : ( ) } , popoverHost() ) } ); } 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 ); } } />
) ) }
{ unit && ( ) }
); } /** Custom dropdown select — a styled replacement for the native ` { setQuery( e.target.value ); setActive( 0 ); } } onKeyDown={ onKeyDown } />
{ filtered.length === 0 ? (
{ __( 'No fonts found', 'everest-forms' ) }
) : ( filtered.map( ( o, i ) => ( ) ) ) }
) } { hint &&
{ hint }
} ); } function AlignControl( props: ControlProps ) { const { token, store } = props; const value = String( store.resolve( token.key ) ); return (
{ [ 'left', 'center', 'right' ].map( ( a ) => ( ) ) }
); } function FontStyleControl( props: ControlProps ) { const { token, store } = props; const value = ( store.resolve( token.key ) || {} ) as FontStyleValue; const weightOptions = token.weight_options || []; return (
{ weightOptions.length > 0 && ( ) }
{ FONTSTYLE_BUTTONS.map( ( [ flag, glyph, title ] ) => (
); } function MediaControl( props: ControlProps ) { const { token, store } = props; const value = String( store.resolve( token.key ) || '' ); const openPicker = () => { const media = ( window as any ).wp?.media; if ( ! media ) { return; } const frame = media( { title: __( 'Select background image', 'everest-forms' ), multiple: false, library: { type: 'image' }, } ); frame.on( 'select', () => { const att = frame.state().get( 'selection' ).first().toJSON(); if ( att && att.url ) { store.setTokenValue( token.key, att.url, false ); } } ); frame.open(); }; return (
{ value && ( ) }
); } function ToggleControl( props: ControlProps ) { const { token, store } = props; // The global "Apply Theme Style" toggle forces fonts.theme on (see store.themeFont()) — disable // this switch rather than leave it clickable-but-inert, and say why. const forcedByGlobal = token.key === 'fonts.theme' && store.applyThemeStyle; const value = forcedByGlobal || store.resolve( token.key ) === true; const hint = forcedByGlobal ? __( 'Forced on by the global “Apply Theme Style” setting above.', 'everest-forms' ) : ''; return ( store.setTokenValue( token.key, ! value, false ) } /> } > { hint &&
{ hint }
}
); } export function ControlRenderer( props: ControlProps & { depHint?: string } ) { switch ( props.token.type ) { case 'slider': return ; case 'color': return ; case 'box4': return ; case 'select': return props.token.source === 'google_fonts' ? ( ) : ( ); case 'align': return ; case 'fontstyle': return ; case 'media': return ; case 'toggle': return ; default: return null; } }