PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / style-customizer-v2 / ControlRenderer.tsx
everest-forms / src / style-customizer-v2 Last commit date
AiAssistant.tsx 2 days ago App.tsx 2 days ago BuilderSync.ts 2 days ago ControlRenderer.tsx 2 days ago HoverTip.tsx 2 days ago Popover.tsx 2 days ago PreviewBridge.ts 2 days ago PreviewPane.tsx 2 days ago constants.ts 2 days ago cssVars.ts 2 days ago globals.d.ts 2 days ago index.tsx 2 days ago panes.tsx 2 days ago store.ts 2 days ago style.scss 2 days ago types.ts 2 days ago
ControlRenderer.tsx
1708 lines
1 /**
2 * Style Customizer v2 — one component per schema control type (slider, color, box4, select,
3 * align, fontstyle, media, toggle), each reading/writing through the store.
4 */
5 import React from 'react';
6 import { createPortal } from 'react-dom';
7 import { HexAlphaColorPicker } from 'react-colorful';
8 import {
9 ALIGN_ICONS,
10 DEVICE_ICONS,
11 DEVICE_LABELS,
12 FONTSTYLE_BUTTONS,
13 clone,
14 } from './constants';
15 import { HoverTip } from './HoverTip';
16 import { StyleStore } from './store';
17 import { BoxValue, FontStyleValue, Token } from './types';
18
19 const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
20
21 /** Same portal target HoverTip uses — inside the panel root (so scoped `#everest-forms-panel-style`
22 * CSS still applies) but outside any scrollable ancestor that would otherwise clip a popover. */
23 function popoverHost(): HTMLElement {
24 return document.getElementById( 'everest-forms-panel-style' ) || document.body;
25 }
26
27 interface ControlProps {
28 token: Token;
29 store: StyleStore;
30 dimmed?: boolean;
31 }
32
33 /* --------------------------------------------------------------------- *
34 * Small helpers
35 * --------------------------------------------------------------------- */
36
37 /** Keep an uncontrolled input's DOM value in sync with state, but never while it's focused. */
38 function useSyncedInput< T extends HTMLInputElement >(
39 ref: React.RefObject< T >,
40 display: string
41 ) {
42 React.useEffect( () => {
43 const el = ref.current;
44 if ( el && el.ownerDocument.activeElement !== el ) {
45 el.value = display;
46 }
47 } );
48 }
49
50 const clampNumber = ( n: number, min: number, max: number ) => Math.min( max, Math.max( min, n ) );
51 const isFloatStep = ( token: Token ) => !! ( token.step && token.step < 1 );
52 const roundToStep = ( n: number, token: Token ) => ( isFloatStep( token ) ? Math.round( n * 10 ) / 10 : Math.round( n ) );
53
54 /** Parse any Sanitizer-accepted colour (#rgb, #rrggbb, #rrggbbaa, rgb()/rgba()) into a 6-digit hex + 0-100 alpha. */
55 function parseColor( value: string ): { hex: string; alpha: number } {
56 const v = value.trim();
57 const rgbaMatch = v.match( /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)$/i );
58 if ( rgbaMatch ) {
59 const toHex = ( n: string ) => clampNumber( Math.round( Number( n ) ), 0, 255 ).toString( 16 ).padStart( 2, '0' );
60 const hex = '#' + toHex( rgbaMatch[ 1 ] ) + toHex( rgbaMatch[ 2 ] ) + toHex( rgbaMatch[ 3 ] );
61 const rawAlpha = rgbaMatch[ 4 ];
62 const alpha = rawAlpha === undefined
63 ? 100
64 : clampNumber( Math.round( rawAlpha.endsWith( '%' ) ? parseFloat( rawAlpha ) : parseFloat( rawAlpha ) * 100 ), 0, 100 );
65 return { hex, alpha };
66 }
67 if ( /^#[0-9a-f]{3}$/i.test( v ) ) {
68 return { hex: '#' + v.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' ), alpha: 100 };
69 }
70 if ( /^#[0-9a-f]{6}$/i.test( v ) ) {
71 return { hex: v.toLowerCase(), alpha: 100 };
72 }
73 if ( /^#[0-9a-f]{8}$/i.test( v ) ) {
74 const alpha = clampNumber( Math.round( ( parseInt( v.slice( 7, 9 ), 16 ) / 255 ) * 100 ), 0, 100 );
75 return { hex: v.slice( 0, 7 ).toLowerCase(), alpha };
76 }
77 return { hex: '#000000', alpha: 100 };
78 }
79
80 /** Recompose a 6-digit hex + 0-100 alpha back into the stored value (plain hex when fully opaque). */
81 function composeColor( hex: string, alpha: number ): string {
82 if ( alpha >= 100 ) {
83 return hex;
84 }
85 const alphaHex = clampNumber( Math.round( ( alpha / 100 ) * 255 ), 0, 255 ).toString( 16 ).padStart( 2, '0' );
86 return hex + alphaHex;
87 }
88
89 /** Always-8-digit form, for react-colorful's `HexAlphaColorPicker` (its value/onChange contract is always `#rrggbbaa`). */
90 function toHex8( hex: string, alpha: number ): string {
91 const alphaHex = clampNumber( Math.round( ( alpha / 100 ) * 255 ), 0, 255 ).toString( 16 ).padStart( 2, '0' );
92 return hex + alphaHex;
93 }
94
95 /** 6-digit hex -> 0-255 RGB triple. */
96 function hexToRgb( hex: string ): { r: number; g: number; b: number } {
97 const n = parseInt( hex.slice( 1 ), 16 ) || 0;
98 return { r: ( n >> 16 ) & 255, g: ( n >> 8 ) & 255, b: n & 255 };
99 }
100
101 /** 0-255 RGB triple -> 6-digit hex. */
102 function rgbToHex( r: number, g: number, b: number ): string {
103 const c = ( n: number ) => clampNumber( Math.round( n ), 0, 255 ).toString( 16 ).padStart( 2, '0' );
104 return '#' + c( r ) + c( g ) + c( b );
105 }
106
107 /** 0-255 RGB triple -> {h: 0-360, s/l: 0-100}. */
108 function rgbToHsl( r: number, g: number, b: number ): { h: number; s: number; l: number } {
109 r /= 255; g /= 255; b /= 255;
110 const max = Math.max( r, g, b );
111 const min = Math.min( r, g, b );
112 const l = ( max + min ) / 2;
113 const d = max - min;
114 let h = 0;
115 let s = 0;
116 if ( d !== 0 ) {
117 s = d / ( 1 - Math.abs( 2 * l - 1 ) );
118 switch ( max ) {
119 case r:
120 h = 60 * ( ( ( g - b ) / d ) % 6 );
121 break;
122 case g:
123 h = 60 * ( ( b - r ) / d + 2 );
124 break;
125 default:
126 h = 60 * ( ( r - g ) / d + 4 );
127 }
128 }
129 if ( h < 0 ) {
130 h += 360;
131 }
132 return { h: Math.round( h ), s: Math.round( s * 100 ), l: Math.round( l * 100 ) };
133 }
134
135 /** {h: 0-360, s/l: 0-100} -> 0-255 RGB triple. */
136 function hslToRgb( h: number, s: number, l: number ): { r: number; g: number; b: number } {
137 h = ( ( h % 360 ) + 360 ) % 360;
138 const sf = clampNumber( s, 0, 100 ) / 100;
139 const lf = clampNumber( l, 0, 100 ) / 100;
140 const c = ( 1 - Math.abs( 2 * lf - 1 ) ) * sf;
141 const x = c * ( 1 - Math.abs( ( ( h / 60 ) % 2 ) - 1 ) );
142 const m = lf - c / 2;
143 let r = 0;
144 let g = 0;
145 let b = 0;
146 if ( h < 60 ) {
147 r = c; g = x; b = 0;
148 } else if ( h < 120 ) {
149 r = x; g = c; b = 0;
150 } else if ( h < 180 ) {
151 r = 0; g = c; b = x;
152 } else if ( h < 240 ) {
153 r = 0; g = x; b = c;
154 } else if ( h < 300 ) {
155 r = x; g = 0; b = c;
156 } else {
157 r = c; g = 0; b = x;
158 }
159 return { r: ( r + m ) * 255, g: ( g + m ) * 255, b: ( b + m ) * 255 };
160 }
161
162 /** Lighten (positive) or darken (negative) a hex colour toward white/black by `amt` (0-1). */
163 function shade( hex: string, amt: number ): string {
164 const { r, g, b } = hexToRgb( hex );
165 const t = amt >= 0 ? 255 : 0;
166 const k = Math.min( 1, Math.abs( amt ) );
167 return rgbToHex( r + ( t - r ) * k, g + ( t - g ) * k, b + ( t - b ) * k );
168 }
169
170 /** A single colour stop on the gradient bar — `color` is any Sanitizer-legal solid value
171 * (hex, 8-digit hex, or rgba()), `pos` is its 0-100 position along the bar. */
172 interface GradStop {
173 color: string;
174 pos: number;
175 }
176
177 /** Whether a token value is one of our gradients — see {@see composeGradient}. */
178 function isGradientValue( value: string ): boolean {
179 return /^(linear|radial)-gradient\(/i.test( String( value || '' ).trim() );
180 }
181
182 /** Split on top-level commas only — skips commas nested inside an `rgba()` stop. Mirrors
183 * {@see Sanitizer::split_top_level_commas} on the PHP side. */
184 function splitTopLevelCommas( str: string ): string[] {
185 const parts: string[] = [];
186 let depth = 0;
187 let cur = '';
188 for ( let i = 0; i < str.length; i++ ) {
189 const ch = str[ i ];
190 if ( ch === '(' ) {
191 depth++;
192 } else if ( ch === ')' ) {
193 depth--;
194 }
195 if ( ch === ',' && depth === 0 ) {
196 parts.push( cur );
197 cur = '';
198 continue;
199 }
200 cur += ch;
201 }
202 parts.push( cur );
203 return parts;
204 }
205
206 /** A fresh, sane 2-stop gradient — used both as the very first gradient a field ever gets and
207 * as a fallback if a hand-authored value doesn't parse. */
208 function defaultGradient(): { angle: number; stops: GradStop[] } {
209 return { angle: 135, stops: [ { color: '#3366cc', pos: 0 }, { color: shade( '#3366cc', -0.35 ), pos: 100 } ] };
210 }
211
212 /** Parse any of our (or hand-authored) `linear-gradient()` values into an editable angle + stop list. */
213 function parseGradient( value: string ): { angle: number; stops: GradStop[] } {
214 const m = String( value ).trim().match( /^linear-gradient\(\s*(-?\d+(?:\.\d+)?)deg\s*,\s*(.+)\)$/i );
215 if ( ! m ) {
216 return defaultGradient();
217 }
218 const angle = Number( m[ 1 ] );
219 const parts = splitTopLevelCommas( m[ 2 ] );
220 const stops: GradStop[] = [];
221 parts.forEach( ( part, i ) => {
222 const mm = part.trim().match( /^(#[0-9a-f]{3,8}|rgba?\([^()]*\))\s*(-?\d+(?:\.\d+)?%)?$/i );
223 if ( ! mm ) {
224 return;
225 }
226 const p = parseColor( mm[ 1 ] );
227 const pos = mm[ 2 ] !== undefined
228 ? clampNumber( parseFloat( mm[ 2 ] ), 0, 100 )
229 : ( parts.length > 1 ? Math.round( ( i / ( parts.length - 1 ) ) * 100 ) : 0 );
230 stops.push( { color: composeColor( p.hex, p.alpha ), pos } );
231 } );
232 return stops.length >= 2 ? { angle, stops } : defaultGradient();
233 }
234
235 /** Stops are always serialized in ascending position order — CSS itself would force this
236 * anyway (a gradient's stops must monotonically increase), so keeping the string that way
237 * avoids the browser silently re-clamping something our own editor didn't expect. */
238 function composeGradient( angle: number, stops: GradStop[] ): string {
239 const sorted = [ ...stops ].sort( ( a, b ) => a.pos - b.pos );
240 return `linear-gradient(${ Math.round( angle ) }deg, ${ sorted.map( ( s ) => `${ s.color } ${ Math.round( s.pos ) }%` ).join( ', ' ) })`;
241 }
242
243 /** The 8 compass directions a CSS gradient angle commonly points — 0deg is "up", clockwise from there. */
244 const ANGLE_COMPASS: Array< { deg: number; area: string } | null > = [
245 { deg: 315, area: 'nw' }, { deg: 0, area: 'n' }, { deg: 45, area: 'ne' },
246 { deg: 270, area: 'w' }, null, { deg: 90, area: 'e' },
247 { deg: 225, area: 'sw' }, { deg: 180, area: 's' }, { deg: 135, area: 'se' },
248 ];
249
250 /** One-click preset directions, laid out like a compass — faster and far more intuitive than
251 * typing a degree value for the common cases, with the numeric field alongside for the rest. */
252 function AngleCompass( { angle, onChange }: { angle: number; onChange: ( deg: number ) => void } ) {
253 const rounded = Math.round( angle );
254 return (
255 <div className="grad-compass" role="group" aria-label={ __( 'Gradient direction', 'everest-forms' ) }>
256 { ANGLE_COMPASS.map( ( cell, i ) =>
257 cell ? (
258 <button
259 key={ i }
260 type="button"
261 className={ 'grad-compass-btn' + ( rounded === cell.deg ? ' is-active' : '' ) }
262 style={ { '--deg': cell.deg + 'deg' } as React.CSSProperties }
263 title={ `${ cell.deg }°` }
264 aria-label={ `${ cell.deg }°` }
265 onClick={ () => onChange( cell.deg ) }
266 >
267 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2.5 } aria-hidden="true">
268 <path d="M12 19V5M12 5l-5 5M12 5l5 5" />
269 </svg>
270 </button>
271 ) : (
272 <span key={ i } className="grad-compass-center" aria-hidden="true" />
273 )
274 ) }
275 </div>
276 );
277 }
278
279 /** One compact "label + number input(+suffix)" field — shared by the RGB/HSL rows below. */
280 function NumField( {
281 label,
282 ariaLabel,
283 value,
284 min,
285 max,
286 suffix,
287 inputRef,
288 onCommit,
289 }: {
290 label: string;
291 ariaLabel: string;
292 value: number;
293 min: number;
294 max: number;
295 suffix?: string;
296 inputRef: React.RefObject< HTMLInputElement >;
297 onCommit: ( n: number ) => void;
298 } ) {
299 return (
300 <div className="cpf-num">
301 <span className="cpf-label">{ label }</span>
302 <div className="num">
303 <input
304 ref={ inputRef }
305 inputMode="numeric"
306 defaultValue={ String( value ) }
307 aria-label={ ariaLabel }
308 onInput={ ( e ) => {
309 const n = Number( ( e.target as HTMLInputElement ).value );
310 if ( ! Number.isNaN( n ) ) {
311 onCommit( clampNumber( n, min, max ) );
312 }
313 } }
314 onBlur={ () => {
315 if ( inputRef.current ) {
316 inputRef.current.value = String( value );
317 }
318 } }
319 />
320 { suffix && <span>{ suffix }</span> }
321 </div>
322 </div>
323 );
324 }
325
326 /** Curated quick-pick row inside every color popover — neutrals, the panel's own accent, and a
327 * handful of common brand/UI colours, so a common choice never requires touching the wheel. */
328 const PRESET_SWATCHES = [
329 '#ffffff', '#f8fafc', '#e5e7eb', '#9ca3af', '#4b5563', '#1f2433', '#111111', '#000000',
330 '#7545bb', '#3b82f6', '#0ea5e9', '#16a34a', '#f59e0b', '#f97316', '#dc2626', '#ec4899',
331 ];
332
333 /**
334 * The full solid-colour editing surface — wheel, HEX/RGB/HSL switch, opacity, eyedropper and
335 * quick-pick presets. Used both as a plain colour popover's body AND, unchanged, as a gradient
336 * stop's own editor — the exact reason a gradient stop no longer feels like a different, lesser
337 * control than every other colour field in the panel.
338 */
339 function SolidColorFields( { label, value, onChange }: { label: string; value: string; onChange: ( color: string ) => void } ) {
340 const parsed = parseColor( value );
341 const popHexRef = React.useRef< HTMLInputElement >( null );
342 const popAlphaNumRef = React.useRef< HTMLInputElement >( null );
343 const [ format, setFormat ] = React.useState< 'hex' | 'rgb' | 'hsl' >( 'hex' );
344 const hasEyeDropper = typeof ( window as any ).EyeDropper !== 'undefined';
345 useSyncedInput( popHexRef, parsed.hex.toUpperCase() );
346 useSyncedInput( popAlphaNumRef, String( parsed.alpha ) );
347
348 const rgb = hexToRgb( parsed.hex );
349 const hsl = rgbToHsl( rgb.r, rgb.g, rgb.b );
350 const rRef = React.useRef< HTMLInputElement >( null );
351 const gRef = React.useRef< HTMLInputElement >( null );
352 const bRef = React.useRef< HTMLInputElement >( null );
353 const hRef = React.useRef< HTMLInputElement >( null );
354 const sRef = React.useRef< HTMLInputElement >( null );
355 const lRef = React.useRef< HTMLInputElement >( null );
356 useSyncedInput( rRef, String( rgb.r ) );
357 useSyncedInput( gRef, String( rgb.g ) );
358 useSyncedInput( bRef, String( rgb.b ) );
359 useSyncedInput( hRef, String( hsl.h ) );
360 useSyncedInput( sRef, String( hsl.s ) );
361 useSyncedInput( lRef, String( hsl.l ) );
362
363 const commitHex = ( hex: string ) => onChange( composeColor( hex, parsed.alpha ) );
364 const commitAlpha = ( alpha: number ) => onChange( composeColor( parsed.hex, clampNumber( alpha, 0, 100 ) ) );
365 const commitHex8 = ( hex8: string ) => {
366 const p = parseColor( hex8 );
367 onChange( composeColor( p.hex, p.alpha ) );
368 };
369 const commitRgb = ( r: number, g: number, b: number ) => commitHex( rgbToHex( r, g, b ) );
370 const commitHsl = ( h: number, s: number, l: number ) => {
371 const c = hslToRgb( h, s, l );
372 commitHex( rgbToHex( c.r, c.g, c.b ) );
373 };
374
375 const onHex = ( e: React.FormEvent< HTMLInputElement > ) => {
376 let t = ( e.target as HTMLInputElement ).value.trim();
377 if ( t && t[ 0 ] !== '#' ) {
378 t = '#' + t;
379 }
380 if ( /^#[0-9a-f]{3}$/i.test( t ) ) {
381 t = '#' + t.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' );
382 }
383 if ( /^#[0-9a-f]{6}$/i.test( t ) ) {
384 commitHex( t.toLowerCase() );
385 }
386 };
387
388 /* Chromium's EyeDropper API — sample any pixel on screen straight into this field.
389 * Feature-detected: the tool button simply doesn't render in browsers without it. */
390 const pickFromScreen = async () => {
391 try {
392 const ED = ( window as any ).EyeDropper;
393 const result = await new ED().open();
394 if ( result?.sRGBHex ) {
395 commitHex( result.sRGBHex.toLowerCase() );
396 }
397 } catch {
398 // User cancelled (Escape) — nothing to do.
399 }
400 };
401
402 return (
403 <>
404 <HexAlphaColorPicker color={ toHex8( parsed.hex, parsed.alpha ) } onChange={ commitHex8 } />
405 <div className="cpf-toolbar">
406 <div className="cpf-format" role="tablist" aria-label={ __( 'Color format', 'everest-forms' ) }>
407 { ( [ 'hex', 'rgb', 'hsl' ] as const ).map( ( f ) => (
408 <button
409 key={ f }
410 type="button"
411 role="tab"
412 aria-selected={ format === f }
413 className={ 'cpf-format-btn' + ( format === f ? ' is-active' : '' ) }
414 onClick={ () => setFormat( f ) }
415 >
416 { f.toUpperCase() }
417 </button>
418 ) ) }
419 </div>
420 { hasEyeDropper && (
421 <button
422 type="button"
423 className="eyedrop-btn"
424 aria-label={ __( 'Pick color from screen', 'everest-forms' ) }
425 title={ __( 'Pick color from screen', 'everest-forms' ) }
426 onClick={ pickFromScreen }
427 >
428 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
429 <path d="m2 22 1-4 9.5-9.5" />
430 <path d="M14.5 6.5 18 3a2.12 2.12 0 0 1 3 3l-3.5 3.5" />
431 <path d="m11.5 8.5 4 4" />
432 <path d="M3 21h4l9.5-9.5-4-4L3 17z" />
433 </svg>
434 </button>
435 ) }
436 </div>
437 <div className="color-pop-fields">
438 { format === 'hex' && (
439 <div className="cpf-hex">
440 <span className="cpf-label">{ __( 'Hex', 'everest-forms' ) }</span>
441 <div className="num">
442 <input
443 ref={ popHexRef }
444 spellCheck={ false }
445 defaultValue={ parsed.hex.toUpperCase() }
446 aria-label={ label + ' ' + __( 'hex value', 'everest-forms' ) }
447 onInput={ onHex }
448 onBlur={ () => {
449 if ( popHexRef.current ) {
450 popHexRef.current.value = parseColor( value ).hex.toUpperCase();
451 }
452 } }
453 />
454 </div>
455 </div>
456 ) }
457 { format === 'rgb' && (
458 <>
459 <NumField
460 label="R"
461 ariaLabel={ label + ' ' + __( 'red value', 'everest-forms' ) }
462 value={ rgb.r }
463 min={ 0 }
464 max={ 255 }
465 inputRef={ rRef }
466 onCommit={ ( n ) => commitRgb( n, rgb.g, rgb.b ) }
467 />
468 <NumField
469 label="G"
470 ariaLabel={ label + ' ' + __( 'green value', 'everest-forms' ) }
471 value={ rgb.g }
472 min={ 0 }
473 max={ 255 }
474 inputRef={ gRef }
475 onCommit={ ( n ) => commitRgb( rgb.r, n, rgb.b ) }
476 />
477 <NumField
478 label="B"
479 ariaLabel={ label + ' ' + __( 'blue value', 'everest-forms' ) }
480 value={ rgb.b }
481 min={ 0 }
482 max={ 255 }
483 inputRef={ bRef }
484 onCommit={ ( n ) => commitRgb( rgb.r, rgb.g, n ) }
485 />
486 </>
487 ) }
488 { format === 'hsl' && (
489 <>
490 <NumField
491 label="H"
492 ariaLabel={ label + ' ' + __( 'hue value', 'everest-forms' ) }
493 value={ hsl.h }
494 min={ 0 }
495 max={ 360 }
496 inputRef={ hRef }
497 onCommit={ ( n ) => commitHsl( n, hsl.s, hsl.l ) }
498 />
499 <NumField
500 label="S"
501 ariaLabel={ label + ' ' + __( 'saturation value', 'everest-forms' ) }
502 value={ hsl.s }
503 min={ 0 }
504 max={ 100 }
505 suffix="%"
506 inputRef={ sRef }
507 onCommit={ ( n ) => commitHsl( hsl.h, n, hsl.l ) }
508 />
509 <NumField
510 label="L"
511 ariaLabel={ label + ' ' + __( 'lightness value', 'everest-forms' ) }
512 value={ hsl.l }
513 min={ 0 }
514 max={ 100 }
515 suffix="%"
516 inputRef={ lRef }
517 onCommit={ ( n ) => commitHsl( hsl.h, hsl.s, n ) }
518 />
519 </>
520 ) }
521 <div className="cpf-alpha">
522 <span className="cpf-label">{ __( 'Opacity', 'everest-forms' ) }</span>
523 <div className="num">
524 <input
525 ref={ popAlphaNumRef }
526 inputMode="numeric"
527 defaultValue={ String( parsed.alpha ) }
528 aria-label={ label + ' ' + __( 'opacity value', 'everest-forms' ) }
529 onInput={ ( e ) => {
530 const n = Number( ( e.target as HTMLInputElement ).value );
531 if ( ! Number.isNaN( n ) ) {
532 commitAlpha( n );
533 }
534 } }
535 onBlur={ () => {
536 if ( popAlphaNumRef.current ) {
537 popAlphaNumRef.current.value = String( parseColor( value ).alpha );
538 }
539 } }
540 />
541 <span>%</span>
542 </div>
543 </div>
544 </div>
545 <div className="color-pop-swatches" role="group" aria-label={ __( 'Preset colors', 'everest-forms' ) }>
546 { PRESET_SWATCHES.map( ( c ) => (
547 <button
548 key={ c }
549 type="button"
550 className="cps-swatch"
551 style={ { background: c } }
552 aria-label={ c }
553 title={ c }
554 onClick={ () => commitHex( c ) }
555 />
556 ) ) }
557 </div>
558 </>
559 );
560 }
561
562 /**
563 * The gradient editing surface — a draggable-stop bar (click empty space to add a stop, drag a
564 * marker to reposition it, arrow keys to nudge), the selected stop's full {@see SolidColorFields}
565 * editor, and a compass + numeric angle control.
566 */
567 function GradientEditor( { label, value, onChange }: { label: string; value: string; onChange: ( v: string ) => void } ) {
568 const grad = parseGradient( value );
569 const [ selected, setSelected ] = React.useState( 0 );
570 const sel = Math.min( selected, grad.stops.length - 1 );
571 const barRef = React.useRef< HTMLDivElement >( null );
572 const angleRef = React.useRef< HTMLInputElement >( null );
573 useSyncedInput( angleRef, String( Math.round( grad.angle ) ) );
574
575 // `commit` always re-sorts by position (composeGradient) before serializing, so a stop's
576 // array index can shift on the very next render — e.g. adding a stop in the middle, or
577 // dragging one past a neighbour. `gradRef`/`selRef` track the latest committed state and
578 // selection synchronously (a plain closure would go stale mid-drag, before React re-renders),
579 // so every add/move recomputes where the stop being edited LANDS after that re-sort and keeps
580 // `selected` — and therefore the caption and the colour editor below — pointing at the same
581 // stop the user is actually holding, not whichever one happens to land at the old index.
582 const gradRef = React.useRef( grad );
583 gradRef.current = grad;
584 const selRef = React.useRef( sel );
585 selRef.current = sel;
586
587 const commit = ( angle: number, stops: GradStop[] ) => onChange( composeGradient( angle, stops ) );
588
589 /** Where a stop at `pos` will rank once the full set is re-sorted ascending (ties settle
590 * after any existing equal-position stop, matching a stable ascending sort). */
591 const rankOf = ( others: GradStop[], pos: number ) => others.filter( ( s ) => s.pos <= pos ).length;
592
593 const setStopColor = ( i: number, color: string ) => {
594 const current = gradRef.current;
595 commit( current.angle, current.stops.map( ( s, idx ) => ( idx === i ? { ...s, color } : s ) ) );
596 };
597 const setStopPos = ( i: number, pos: number ) => {
598 const current = gradRef.current;
599 const clamped = clampNumber( pos, 0, 100 );
600 const stops = current.stops.map( ( s, idx ) => ( idx === i ? { ...s, pos: clamped } : s ) );
601 const rank = rankOf( stops.filter( ( _, idx ) => idx !== i ), clamped );
602 selRef.current = rank;
603 setSelected( rank );
604 commit( current.angle, stops );
605 };
606 const addStop = ( pos: number ) => {
607 const current = gradRef.current;
608 const sorted = [ ...current.stops ].sort( ( a, b ) => a.pos - b.pos );
609 let color = sorted[ 0 ].color;
610 for ( let i = 0; i < sorted.length - 1; i++ ) {
611 if ( pos >= sorted[ i ].pos ) {
612 color = sorted[ i ].color;
613 }
614 }
615 const rank = rankOf( sorted, pos );
616 selRef.current = rank;
617 setSelected( rank );
618 commit( current.angle, [ ...current.stops, { color, pos } ] );
619 };
620 const removeStop = ( i: number ) => {
621 const current = gradRef.current;
622 if ( current.stops.length <= 2 ) {
623 return;
624 }
625 selRef.current = 0;
626 setSelected( 0 );
627 commit( current.angle, current.stops.filter( ( _, idx ) => idx !== i ) );
628 };
629
630 const posFromClientX = ( clientX: number ) => {
631 const el = barRef.current;
632 if ( ! el ) {
633 return 0;
634 }
635 const r = el.getBoundingClientRect();
636 return clampNumber( ( ( clientX - r.left ) / r.width ) * 100, 0, 100 );
637 };
638
639 // A drag that ends over the bar itself (not back on the marker) fires the bar's own click
640 // right after — per the DOM spec, when mousedown/mouseup targets differ, click bubbles to
641 // their common ancestor, which here is the bar — so a completed drag would otherwise always
642 // add a spurious extra stop right where the marker was just dropped.
643 const justDraggedRef = React.useRef( false );
644
645 const startDrag = ( i: number ) => ( e: React.MouseEvent ) => {
646 e.preventDefault();
647 e.stopPropagation();
648 selRef.current = i;
649 setSelected( i );
650 let moved = false;
651 const move = ( ev: MouseEvent ) => {
652 moved = true;
653 setStopPos( selRef.current, posFromClientX( ev.clientX ) );
654 };
655 const up = () => {
656 window.removeEventListener( 'mousemove', move );
657 window.removeEventListener( 'mouseup', up );
658 justDraggedRef.current = moved;
659 };
660 window.addEventListener( 'mousemove', move );
661 window.addEventListener( 'mouseup', up );
662 };
663
664 const onBarClick = ( e: React.MouseEvent ) => {
665 if ( justDraggedRef.current ) {
666 justDraggedRef.current = false;
667 return;
668 }
669 addStop( posFromClientX( e.clientX ) );
670 };
671
672 const barCss = composeGradient( 90, grad.stops );
673
674 return (
675 <div className="grad-editor">
676 <div
677 ref={ barRef }
678 className="grad-bar"
679 style={ { backgroundImage: barCss } }
680 title={ __( 'Click to add a stop', 'everest-forms' ) }
681 onClick={ onBarClick }
682 role="group"
683 aria-label={ __( 'Gradient stops — click to add, drag to reposition', 'everest-forms' ) }
684 >
685 { grad.stops.map( ( s, i ) => (
686 <button
687 key={ i }
688 type="button"
689 className={ 'grad-marker' + ( sel === i ? ' is-selected' : '' ) }
690 style={ { left: s.pos + '%', '--marker-color': s.color } as React.CSSProperties }
691 onMouseDown={ startDrag( i ) }
692 onClick={ ( e ) => e.stopPropagation() }
693 onKeyDown={ ( e ) => {
694 if ( e.key === 'ArrowLeft' || e.key === 'ArrowRight' ) {
695 e.preventDefault();
696 const delta = ( e.key === 'ArrowLeft' ? -1 : 1 ) * ( e.shiftKey ? 5 : 1 );
697 setSelected( i );
698 setStopPos( i, s.pos + delta );
699 }
700 } }
701 aria-label={ `${ __( 'Stop', 'everest-forms' ) } ${ i + 1 }, ${ Math.round( s.pos ) }%` }
702 />
703 ) ) }
704 </div>
705 <p className="grad-hint">{ __( 'Click the bar to add a stop, drag a marker to move it.', 'everest-forms' ) }</p>
706 <div className="grad-stop-row">
707 <span className="grad-stop-caption">
708 { __( 'Stop', 'everest-forms' ) } { sel + 1 } · { Math.round( grad.stops[ sel ].pos ) }%
709 </span>
710 { grad.stops.length > 2 && (
711 <button
712 type="button"
713 className="grad-stop-remove"
714 aria-label={ __( 'Remove this stop', 'everest-forms' ) }
715 title={ __( 'Remove this stop', 'everest-forms' ) }
716 onClick={ () => removeStop( sel ) }
717 >
718 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
719 <path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
720 </svg>
721 </button>
722 ) }
723 </div>
724 <SolidColorFields
725 label={ `${ label }${ __( 'stop', 'everest-forms' ) } ${ sel + 1 }` }
726 value={ grad.stops[ sel ].color }
727 onChange={ ( c ) => setStopColor( sel, c ) }
728 />
729 <div className="grad-angle-row">
730 <AngleCompass angle={ grad.angle } onChange={ ( deg ) => commit( deg, grad.stops ) } />
731 <NumField
732 label={ __( 'Angle', 'everest-forms' ) }
733 ariaLabel={ label + ' ' + __( 'gradient angle', 'everest-forms' ) }
734 value={ Math.round( grad.angle ) }
735 min={ 0 }
736 max={ 359 }
737 suffix="°"
738 inputRef={ angleRef }
739 onCommit={ ( n ) => commit( n, grad.stops ) }
740 />
741 </div>
742 </div>
743 );
744 }
745
746 function Svg( { inner, className }: { inner: string; className?: string } ) {
747 return (
748 <svg
749 viewBox="0 0 24 24"
750 fill="none"
751 stroke="currentColor"
752 strokeWidth={ 2 }
753 className={ className }
754 dangerouslySetInnerHTML={ { __html: inner } }
755 />
756 );
757 }
758
759 /** Shared chevron-down glyph for every custom dropdown trigger (selects). */
760 function ChevronDownIcon() {
761 return (
762 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
763 <path d="m6 9 6 6 6-6" />
764 </svg>
765 );
766 }
767
768 /** Shared selected-item checkmark for every custom dropdown list. */
769 function CheckIcon() {
770 return (
771 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2.5 } aria-hidden="true">
772 <path d="M20 6 9 17l-5-5" />
773 </svg>
774 );
775 }
776
777 /** Close a dropdown on outside click / Escape — shared by every custom select below.
778 * `extraRef` covers content that lives outside `rootRef` in the DOM (e.g. a portaled popover),
779 * so a click inside IT doesn't count as "outside" either. */
780 function useDismiss(
781 open: boolean,
782 rootRef: React.RefObject< HTMLElement >,
783 onDismiss: () => void,
784 extraRef?: React.RefObject< HTMLElement >
785 ) {
786 React.useEffect( () => {
787 if ( ! open ) {
788 return;
789 }
790 const onDown = ( e: MouseEvent ) => {
791 const target = e.target as Node;
792 if ( rootRef.current && rootRef.current.contains( target ) ) {
793 return;
794 }
795 if ( extraRef?.current && extraRef.current.contains( target ) ) {
796 return;
797 }
798 onDismiss();
799 };
800 const onKey = ( e: KeyboardEvent ) => {
801 if ( e.key === 'Escape' ) {
802 onDismiss();
803 }
804 };
805 document.addEventListener( 'mousedown', onDown );
806 document.addEventListener( 'keydown', onKey );
807 return () => {
808 document.removeEventListener( 'mousedown', onDown );
809 document.removeEventListener( 'keydown', onKey );
810 };
811 // eslint-disable-next-line react-hooks/exhaustive-deps
812 }, [ open ] );
813 }
814
815 /* --------------------------------------------------------------------- *
816 * Shared label row + shell
817 * --------------------------------------------------------------------- */
818
819 /** Body of the device-badge hover tooltip: explains the per-device state of a responsive token. */
820 function ResponsiveTip( { token, store, override }: { token: Token; store: StyleStore; override: boolean } ) {
821 return (
822 <>
823 <div className="hovertip-title">
824 <b>{ token.label }</b>{ __( 'responsive control', 'everest-forms' ) }
825 </div>
826 <div className="hovertip-body">
827 { store.device === 'desktop' ? (
828 __(
829 'You’re editing the Desktop base value. Switch to tablet or mobile to set a per-device override.',
830 'everest-forms'
831 )
832 ) : (
833 <>
834 { __( 'Editing', 'everest-forms' ) } <b>{ DEVICE_LABELS[ store.device ] }</b>{ ' ' }
835 { override
836 ? __( 'this device has its own value. Use the reset button to remove it.', 'everest-forms' )
837 : __( 'currently inheriting the Desktop value.', 'everest-forms' ) }
838 </>
839 ) }
840 </div>
841 </>
842 );
843 }
844
845 function ControlShell( {
846 token,
847 store,
848 right,
849 inlineRight,
850 children,
851 dimmed,
852 }: ControlProps & { right?: React.ReactNode; inlineRight?: React.ReactNode; children: React.ReactNode } ) {
853 const changed = store.isChanged( token.key );
854 const inherited = token.responsive && store.device !== 'desktop' && ! store.isOverride( token.key );
855 const override = store.isOverride( token.key );
856
857 const classNames = [ 'ctrl' ];
858 if ( changed ) {
859 classNames.push( 'changed' );
860 }
861 if ( inherited ) {
862 classNames.push( 'inherited' );
863 }
864 if ( dimmed ) {
865 classNames.push( 'shared-dim' );
866 }
867
868 return (
869 <div className={ classNames.join( ' ' ) } data-k={ token.key }>
870 <div className="ctrl-lab">
871 <span className="lab-left">
872 <label>{ token.label }</label>
873 { token.responsive && store.device !== 'desktop' && (
874 <HoverTip
875 className={ 'dev-badge' + ( override ? ' override' : '' ) }
876 label={ token.label + '' + __( 'responsive options', 'everest-forms' ) }
877 tip={ <ResponsiveTip token={ token } store={ store } override={ override } /> }
878 >
879 <Svg inner={ DEVICE_ICONS[ store.device ] } />
880 </HoverTip>
881 ) }
882 { inlineRight }
883 </span>
884 <span className="lab-right">
885 <button
886 type="button"
887 className="prop-reset"
888 title={
889 token.responsive && store.device !== 'desktop'
890 ? __( 'Remove override', 'everest-forms' )
891 : __( 'Reset to default', 'everest-forms' )
892 }
893 aria-label={ __( 'Reset', 'everest-forms' ) + ' ' + token.label }
894 onClick={ () => store.resetToken( token.key ) }
895 >
896 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
897 <path d="M3 12a9 9 0 1 0 3-6.7" />
898 <path d="M3 4v5h5" />
899 </svg>
900 </button>
901 { right }
902 </span>
903 </div>
904 { children }
905 </div>
906 );
907 }
908
909 /* --------------------------------------------------------------------- *
910 * Controls
911 * --------------------------------------------------------------------- */
912
913 function SliderControl( props: ControlProps ) {
914 const { token, store } = props;
915 const value = Number( store.resolve( token.key ) );
916 const unit = token.unit !== undefined ? token.unit : 'px';
917 const min = token.min ?? 0;
918 const max = token.max ?? 300;
919 const numRef = React.useRef< HTMLInputElement >( null );
920 useSyncedInput( numRef, String( value ) );
921
922 const commit = ( raw: number, gesture: boolean ) => {
923 if ( Number.isNaN( raw ) ) {
924 return;
925 }
926 store.setTokenValue( token.key, roundToStep( clampNumber( raw, min, max ), token ), gesture );
927 };
928
929 // The native thumb travels inset by half its own width from each track edge (matches CSS).
930 const THUMB = 15;
931 const pct = max > min ? clampNumber( ( ( value - min ) / ( max - min ) ) * 100, 0, 100 ) : 0;
932
933 return (
934 <ControlShell { ...props }>
935 <div className="slider">
936 <div className="slider-track">
937 <input
938 type="range"
939 min={ min }
940 max={ max }
941 step={ token.step || 1 }
942 value={ value }
943 aria-label={ token.label }
944 aria-valuetext={ `${ value }${ unit }` }
945 style={ { '--fill': `${ pct }%` } as React.CSSProperties }
946 onChange={ ( e ) => commit( Number( e.target.value ), true ) }
947 />
948 <span
949 className="slider-tip"
950 aria-hidden="true"
951 style={ { left: `calc((100% - ${ THUMB }px) * ${ pct / 100 } + ${ THUMB / 2 }px)` } }
952 >
953 { value }{ unit }
954 </span>
955 </div>
956 <div className="num">
957 <input
958 ref={ numRef }
959 inputMode="decimal"
960 defaultValue={ String( value ) }
961 aria-label={ token.label + ' value' }
962 onInput={ ( e ) => {
963 const n = isFloatStep( token )
964 ? parseFloat( ( e.target as HTMLInputElement ).value )
965 : parseInt( ( e.target as HTMLInputElement ).value, 10 );
966 commit( n, true );
967 } }
968 onBlur={ () => {
969 if ( numRef.current ) {
970 numRef.current.value = String( store.resolve( token.key ) );
971 }
972 } }
973 onKeyDown={ ( e ) => {
974 if ( e.key !== 'ArrowUp' && e.key !== 'ArrowDown' ) {
975 return;
976 }
977 e.preventDefault();
978 const base = token.step || 1;
979 const step = e.shiftKey ? base * 10 : base;
980 commit( Number( store.resolve( token.key ) ) + ( e.key === 'ArrowUp' ? step : -step ), true );
981 ( e.currentTarget as HTMLInputElement ).value = String( store.resolve( token.key ) );
982 } }
983 />
984 { unit && <span>{ unit }</span> }
985 </div>
986 </div>
987 </ControlShell>
988 );
989 }
990
991 /**
992 * Swatch + hex box that opens a popover with a full saturation/hue/alpha picker and an
993 * "Opacity %" field — the one color-editing surface every part of the panel should share
994 * (element controls, "Your Palette" slots, anywhere else a raw color needs editing).
995 * Store/token-agnostic on purpose: the caller decides what `value` means and what `onChange`
996 * does with the recomposed color string.
997 */
998 export function ColorPickerField( {
999 label,
1000 value,
1001 onChange,
1002 gradientable,
1003 }: {
1004 label: string;
1005 value: string;
1006 onChange: ( color: string ) => void;
1007 /** Whether this token's CSS rule uses the `background` shorthand — see {@see Token.gradientable}. */
1008 gradientable?: boolean;
1009 } ) {
1010 const isGrad = gradientable && isGradientValue( value );
1011 const parsed = parseColor( isGrad ? '' : value );
1012 const hexRef = React.useRef< HTMLInputElement >( null );
1013 const rootRef = React.useRef< HTMLDivElement >( null );
1014 const swatchRef = React.useRef< HTMLButtonElement >( null );
1015 const popRef = React.useRef< HTMLDivElement >( null );
1016 const [ invalid, setInvalid ] = React.useState( false );
1017 const [ pickerOpen, setPickerOpen ] = React.useState( false );
1018 const [ pos, setPos ] = React.useState< { left: number; top: number } | null >( null );
1019 useSyncedInput( hexRef, parsed.hex.toUpperCase() );
1020 useDismiss( pickerOpen, rootRef, () => setPickerOpen( false ), popRef );
1021
1022 // Gradient mode (only reachable when `gradientable`). Deriving a sane default FROM the
1023 // current solid colour (rather than an arbitrary stock gradient) means switching modes
1024 // never jars — the preview always starts from what was already on screen.
1025 const grad = isGrad
1026 ? parseGradient( value )
1027 : { angle: 135, stops: [ { color: composeColor( parsed.hex, parsed.alpha ), pos: 0 }, { color: shade( parsed.hex, -0.35 ), pos: 100 } ] };
1028
1029 // Portaled + position:fixed (see below) so the popover can never be clipped by a scrolling
1030 // ancestor (the panel sidebar, a palette's own scrollable row list, etc.) — same escape-hatch
1031 // HoverTip already uses for its own tooltip.
1032 const updatePos = React.useCallback( () => {
1033 const trigger = swatchRef.current;
1034 if ( ! trigger ) {
1035 return;
1036 }
1037 const r = trigger.getBoundingClientRect();
1038 const width = popRef.current?.offsetWidth || 264;
1039 const height = popRef.current?.offsetHeight || 0;
1040 const margin = 8;
1041 // WordPress's own fixed admin bar (32px on desktop, 46px under ~600px wide) sits above
1042 // everything at the very top of the page — clamping to 8px from the viewport edge (as
1043 // this used to) can still land the popover right underneath it, close enough that only
1044 // a sliver of the popover's own heading peeks out below the bar on a short screen where
1045 // the flip-above branch kicks in. Keep clear of it outright instead of guessing its exact
1046 // height: WP renders it with `#wpadminbar`.
1047 const adminBar = document.getElementById( 'wpadminbar' );
1048 const topBound = margin + ( adminBar ? adminBar.getBoundingClientRect().bottom : 0 );
1049 let left = r.left;
1050 left = Math.min( Math.max( margin, left ), window.innerWidth - width - margin );
1051 // Pick whichever side actually has more room, rather than "below unless it doesn't fit,
1052 // then blindly above" — the old rule could flip to a side with EVEN LESS room on a short
1053 // screen. Then hard-clamp both edges so a popover taller than either side still lands
1054 // fully on-screen (its own max-height + overflow-y:auto, see style.scss, takes it from
1055 // there if it's taller than the whole viewport).
1056 const roomBelow = window.innerHeight - r.bottom - margin;
1057 const roomAbove = r.top - topBound;
1058 const top = ! height || height <= roomBelow || roomBelow >= roomAbove
1059 ? r.bottom + 6
1060 : r.top - height - 6;
1061 setPos( { left, top: clampNumber( top, topBound, Math.max( topBound, window.innerHeight - height - margin ) ) } );
1062 }, [] );
1063
1064 React.useLayoutEffect( () => {
1065 if ( pickerOpen ) {
1066 updatePos();
1067 } else {
1068 setPos( null );
1069 }
1070 }, [ pickerOpen, updatePos ] );
1071
1072 React.useEffect( () => {
1073 if ( ! pickerOpen ) {
1074 return;
1075 }
1076 window.addEventListener( 'scroll', updatePos, true );
1077 window.addEventListener( 'resize', updatePos );
1078 return () => {
1079 window.removeEventListener( 'scroll', updatePos, true );
1080 window.removeEventListener( 'resize', updatePos );
1081 };
1082 }, [ pickerOpen, updatePos ] );
1083
1084 const commitHex = ( hex: string ) => onChange( composeColor( hex, parsed.alpha ) );
1085 const switchToGradient = () => onChange( composeGradient( grad.angle, grad.stops ) );
1086 const switchToSolid = () => {
1087 const p = parseColor( grad.stops[ 0 ].color );
1088 onChange( composeColor( p.hex, p.alpha ) );
1089 };
1090
1091 const onHex = ( e: React.FormEvent< HTMLInputElement > ) => {
1092 let t = ( e.target as HTMLInputElement ).value.trim();
1093 if ( t && t[ 0 ] !== '#' ) {
1094 t = '#' + t;
1095 }
1096 if ( /^#[0-9a-f]{3}$/i.test( t ) ) {
1097 t = '#' + t.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' );
1098 }
1099 if ( /^#[0-9a-f]{6}$/i.test( t ) ) {
1100 setInvalid( false );
1101 commitHex( t.toLowerCase() );
1102 } else {
1103 setInvalid( true );
1104 }
1105 };
1106
1107 return (
1108 <div className={ 'color' + ( invalid ? ' invalid' : '' ) } ref={ rootRef }>
1109 <button
1110 ref={ swatchRef }
1111 type="button"
1112 className="swatch"
1113 style={
1114 isGrad
1115 ? ( { backgroundImage: value, backgroundSize: '100% 100%' } as React.CSSProperties )
1116 : ( { '--swatch': composeColor( parsed.hex, parsed.alpha ) } as React.CSSProperties )
1117 }
1118 aria-haspopup="true"
1119 aria-expanded={ pickerOpen }
1120 aria-label={ label }
1121 onClick={ () => setPickerOpen( ( o ) => ! o ) }
1122 />
1123 { isGrad ? (
1124 <span className="hex hex-grad-label">{ __( 'Gradient', 'everest-forms' ) }</span>
1125 ) : (
1126 <input
1127 ref={ hexRef }
1128 className="hex"
1129 spellCheck={ false }
1130 defaultValue={ parsed.hex.toUpperCase() }
1131 aria-label={ label + ' hex value' }
1132 onInput={ onHex }
1133 onBlur={ () => {
1134 setInvalid( false );
1135 if ( hexRef.current ) {
1136 hexRef.current.value = parseColor( value ).hex.toUpperCase();
1137 }
1138 } }
1139 />
1140 ) }
1141 { invalid && <span className="err">{ __( 'Invalid color', 'everest-forms' ) }</span> }
1142 { pickerOpen &&
1143 createPortal(
1144 <div
1145 ref={ popRef }
1146 className="color-pop"
1147 style={ { left: ( pos || { left: -9999, top: -9999 } ).left, top: ( pos || { left: -9999, top: -9999 } ).top } }
1148 >
1149 <div className="color-pop-head">
1150 <span>{ label }</span>
1151 </div>
1152 { gradientable && (
1153 <div className="cpf-format cpf-format--mode" role="tablist" aria-label={ __( 'Fill type', 'everest-forms' ) }>
1154 <button
1155 type="button"
1156 role="tab"
1157 aria-selected={ ! isGrad }
1158 className={ 'cpf-format-btn' + ( ! isGrad ? ' is-active' : '' ) }
1159 onClick={ switchToSolid }
1160 >
1161 { __( 'Solid', 'everest-forms' ) }
1162 </button>
1163 <button
1164 type="button"
1165 role="tab"
1166 aria-selected={ isGrad }
1167 className={ 'cpf-format-btn' + ( isGrad ? ' is-active' : '' ) }
1168 onClick={ switchToGradient }
1169 >
1170 { __( 'Gradient', 'everest-forms' ) }
1171 </button>
1172 </div>
1173 ) }
1174 { isGrad ? (
1175 <GradientEditor label={ label } value={ value } onChange={ onChange } />
1176 ) : (
1177 <SolidColorFields label={ label } value={ value } onChange={ onChange } />
1178 ) }
1179 </div>,
1180 popoverHost()
1181 ) }
1182 </div>
1183 );
1184 }
1185
1186 function ColorControl( props: ControlProps ) {
1187 const { token, store } = props;
1188 const value = String( store.resolve( token.key ) );
1189 return (
1190 <ControlShell { ...props }>
1191 <ColorPickerField
1192 label={ token.label }
1193 value={ value }
1194 onChange={ ( color ) => store.setTokenValue( token.key, color, true ) }
1195 gradientable={ !! token.gradientable }
1196 />
1197 </ControlShell>
1198 );
1199 }
1200
1201 const SIDE_LABELS = [ 'Top', 'Right', 'Bottom', 'Left' ] as const;
1202 const CORNER_LABELS = [ 'Top-left', 'Top-right', 'Bottom-right', 'Bottom-left' ] as const;
1203 const SIDE_ABBR = [ 'T', 'R', 'B', 'L' ];
1204 const CORNER_ABBR = [ 'TL', 'TR', 'BR', 'BL' ];
1205 const BOX_KEYS: Array< keyof BoxValue > = [ 'top', 'right', 'bottom', 'left' ];
1206
1207 /** Are all four box sides currently equal? Seeds the initial "link sides" state. */
1208 function allSidesEqual( v: BoxValue ): boolean {
1209 return v.top === v.right && v.right === v.bottom && v.bottom === v.left;
1210 }
1211
1212 function Box4Control( props: ControlProps ) {
1213 const { token, store } = props;
1214 const value = clone( store.resolve( token.key ) ) as BoxValue;
1215 const cellRefs = [
1216 React.useRef< HTMLInputElement >( null ),
1217 React.useRef< HTMLInputElement >( null ),
1218 React.useRef< HTMLInputElement >( null ),
1219 React.useRef< HTMLInputElement >( null ),
1220 ];
1221 const [ linked, setLinked ] = React.useState( () => allSidesEqual( value ) );
1222
1223 const min = token.min ?? ( token.key.indexOf( 'margin' ) !== -1 ? -1000 : 0 );
1224 const max = token.max ?? 1000;
1225 const abbr = token.corners ? CORNER_ABBR : SIDE_ABBR;
1226 const labels = token.corners ? CORNER_LABELS : SIDE_LABELS;
1227 const unit = token.units && token.units.length ? value.unit || token.units[ 0 ] : null;
1228
1229 React.useEffect( () => {
1230 cellRefs.forEach( ( ref, i ) => {
1231 const el = ref.current;
1232 if ( el && el.ownerDocument.activeElement !== el ) {
1233 el.value = String( value[ BOX_KEYS[ i ] ] ?? 0 );
1234 }
1235 } );
1236 } );
1237
1238 const commit = ( index: number, raw: number ) => {
1239 if ( Number.isNaN( raw ) ) {
1240 return;
1241 }
1242 const n = clampNumber( raw, min, max );
1243 const next = clone( store.resolve( token.key ) ) as BoxValue;
1244 if ( linked ) {
1245 BOX_KEYS.forEach( ( k ) => ( next[ k ] = n ) );
1246 cellRefs.forEach( ( r ) => r.current && ( r.current.value = String( n ) ) );
1247 } else {
1248 next[ BOX_KEYS[ index ] ] = n;
1249 }
1250 store.setTokenValue( token.key, next, true );
1251 };
1252
1253 const toggleUnit = () => {
1254 if ( ! token.units || token.units.length < 2 ) {
1255 return;
1256 }
1257 const next = clone( store.resolve( token.key ) ) as BoxValue;
1258 const cur = next.unit || token.units[ 0 ];
1259 next.unit = token.units[ ( token.units.indexOf( cur ) + 1 ) % token.units.length ];
1260 store.setTokenValue( token.key, next, false );
1261 };
1262
1263 return (
1264 <ControlShell { ...props } right={ ! token.units ? <span className="px-hint">px</span> : undefined }>
1265 <div className="box4">
1266 <div className="box4-cells">
1267 <div className="box4-inputs">
1268 { BOX_KEYS.map( ( _k, i ) => (
1269 <div className="cell" key={ i }>
1270 <div className="num">
1271 <input
1272 ref={ cellRefs[ i ] }
1273 inputMode="numeric"
1274 defaultValue={ String( value[ BOX_KEYS[ i ] ] ?? 0 ) }
1275 aria-label={ token.label + ' ' + labels[ i ] }
1276 onInput={ ( e ) => commit( i, parseInt( ( e.target as HTMLInputElement ).value, 10 ) ) }
1277 onBlur={ ( e ) => {
1278 ( e.target as HTMLInputElement ).value = String(
1279 ( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0
1280 );
1281 } }
1282 onKeyDown={ ( e ) => {
1283 if ( e.key !== 'ArrowUp' && e.key !== 'ArrowDown' ) {
1284 return;
1285 }
1286 e.preventDefault();
1287 const cur = ( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0;
1288 const step = e.shiftKey ? 10 : 1;
1289 commit( i, Number( cur ) + ( e.key === 'ArrowUp' ? step : -step ) );
1290 ( e.currentTarget as HTMLInputElement ).value = String(
1291 ( clone( store.resolve( token.key ) ) as BoxValue )[ BOX_KEYS[ i ] ] ?? 0
1292 );
1293 } }
1294 />
1295 </div>
1296 </div>
1297 ) ) }
1298 </div>
1299 <div className="box4-abbr" aria-hidden="true">
1300 { BOX_KEYS.map( ( _k, i ) => (
1301 <small key={ i }>{ abbr[ i ] }</small>
1302 ) ) }
1303 </div>
1304 </div>
1305 <button
1306 type="button"
1307 className="link"
1308 aria-pressed={ linked }
1309 aria-label={ token.label + '' + __( 'link sides', 'everest-forms' ) }
1310 title={ __( 'Link sides', 'everest-forms' ) }
1311 onClick={ () => setLinked( ! linked ) }
1312 >
1313 <Svg inner='<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>' />
1314 </button>
1315 { unit && (
1316 <button
1317 type="button"
1318 className="unit-tgl"
1319 aria-label={
1320 token.label + '' + __( 'unit', 'everest-forms' ) + ': ' + unit + '. ' +
1321 __( 'Click to change.', 'everest-forms' )
1322 }
1323 onClick={ toggleUnit }
1324 >
1325 { unit }
1326 </button>
1327 ) }
1328 </div>
1329 </ControlShell>
1330 );
1331 }
1332
1333 /** Custom dropdown select — a styled replacement for the native `<select>`. */
1334 function SelectControl( props: ControlProps & { depHint?: string } ) {
1335 const { token, store, depHint } = props;
1336 const value = String( store.resolve( token.key ) );
1337 const options = token.options || [];
1338 const current = options.find( ( o ) => o.value === value );
1339 const [ open, setOpen ] = React.useState( false );
1340 const rootRef = React.useRef< HTMLDivElement >( null );
1341
1342 useDismiss( open, rootRef, () => setOpen( false ) );
1343
1344 const choose = ( v: string ) => {
1345 store.setTokenValue( token.key, v, false );
1346 setOpen( false );
1347 };
1348
1349 return (
1350 <ControlShell { ...props }>
1351 <div className="dsel" ref={ rootRef }>
1352 <button
1353 type="button"
1354 className="dsel-trigger"
1355 aria-haspopup="listbox"
1356 aria-expanded={ open }
1357 aria-label={ token.label }
1358 onClick={ () => setOpen( ( o ) => ! o ) }
1359 >
1360 <span className="dsel-val">{ current ? current.label : value }</span>
1361 <span className="dsel-chev">
1362 <ChevronDownIcon />
1363 </span>
1364 </button>
1365 { open && (
1366 <div className="dsel-pop">
1367 <div className="dsel-list" role="listbox" aria-label={ token.label }>
1368 { options.map( ( o ) => (
1369 <button
1370 key={ o.value }
1371 type="button"
1372 role="option"
1373 aria-selected={ o.value === value }
1374 className={ 'dsel-opt' + ( o.value === value ? ' sel' : '' ) }
1375 onClick={ () => choose( o.value ) }
1376 >
1377 <span className="dsel-check">{ o.value === value && <CheckIcon /> }</span>
1378 { o.label }
1379 </button>
1380 ) ) }
1381 </div>
1382 </div>
1383 ) }
1384 </div>
1385 { depHint && <div className="dep-hint">{ depHint }</div> }
1386 </ControlShell>
1387 );
1388 }
1389
1390 /** Searchable font-family picker (combobox) for the ~1000-entry Google Fonts list. */
1391 function FontSelectControl( props: ControlProps & { depHint?: string } ) {
1392 const { token, store, depHint } = props;
1393 const value = String( store.resolve( token.key ) );
1394 const themeFont = store.themeFont();
1395 const disabled = themeFont;
1396
1397 const THEME_DEFAULT = __( 'Theme default', 'everest-forms' );
1398 const [ open, setOpen ] = React.useState( false );
1399 const [ query, setQuery ] = React.useState( '' );
1400 const [ active, setActive ] = React.useState( 0 );
1401 const rootRef = React.useRef< HTMLDivElement >( null );
1402 const listRef = React.useRef< HTMLDivElement >( null );
1403 const searchRef = React.useRef< HTMLInputElement >( null );
1404
1405 const allOptions = React.useMemo( () => {
1406 const base = [ { value: '', label: THEME_DEFAULT } ];
1407 ( store.googleFonts || [] ).forEach( ( f ) => base.push( { value: f, label: f } ) );
1408 return base;
1409 // eslint-disable-next-line react-hooks/exhaustive-deps
1410 }, [ store.googleFonts ] );
1411
1412 const filtered = React.useMemo( () => {
1413 const q = query.trim().toLowerCase();
1414 return q ? allOptions.filter( ( o ) => o.label.toLowerCase().indexOf( q ) !== -1 ) : allOptions;
1415 }, [ query, allOptions ] );
1416
1417 useDismiss( open, rootRef, () => setOpen( false ) );
1418
1419 React.useEffect( () => {
1420 if ( ! open ) {
1421 return;
1422 }
1423 setQuery( '' );
1424 const idx = allOptions.findIndex( ( o ) => o.value === value );
1425 setActive( idx >= 0 ? idx : 0 );
1426 const t = window.setTimeout( () => searchRef.current && searchRef.current.focus(), 0 );
1427 return () => window.clearTimeout( t );
1428 // eslint-disable-next-line react-hooks/exhaustive-deps
1429 }, [ open ] );
1430
1431 React.useEffect( () => {
1432 if ( ! open || ! listRef.current ) {
1433 return;
1434 }
1435 const el = listRef.current.children[ active ] as HTMLElement | undefined;
1436 if ( el && el.scrollIntoView ) {
1437 el.scrollIntoView( { block: 'nearest' } );
1438 }
1439 }, [ active, open ] );
1440
1441 const choose = ( v: string ) => {
1442 store.setTokenValue( token.key, v, false );
1443 setOpen( false );
1444 };
1445
1446 const onKeyDown = ( e: React.KeyboardEvent ) => {
1447 if ( e.key === 'ArrowDown' ) {
1448 e.preventDefault();
1449 setActive( ( a ) => Math.min( filtered.length - 1, a + 1 ) );
1450 } else if ( e.key === 'ArrowUp' ) {
1451 e.preventDefault();
1452 setActive( ( a ) => Math.max( 0, a - 1 ) );
1453 } else if ( e.key === 'Enter' ) {
1454 e.preventDefault();
1455 if ( filtered[ active ] ) {
1456 choose( filtered[ active ].value );
1457 }
1458 } else if ( e.key === 'Escape' ) {
1459 e.preventDefault();
1460 setOpen( false );
1461 }
1462 };
1463
1464 const hint = ! themeFont
1465 ? depHint || ''
1466 : store.applyThemeStyle
1467 ? __( 'Using your theme’s font — controlled by “Apply Theme Style” above.', 'everest-forms' )
1468 : __( 'Using your theme’s font. Turn off “Use theme fonts” to choose one.', 'everest-forms' );
1469
1470 return (
1471 <ControlShell { ...props }>
1472 <div className="dsel" ref={ rootRef }>
1473 <button
1474 type="button"
1475 className="dsel-trigger"
1476 disabled={ disabled }
1477 aria-haspopup="listbox"
1478 aria-expanded={ open }
1479 aria-label={ token.label }
1480 onClick={ () => ! disabled && setOpen( ( o ) => ! o ) }
1481 >
1482 <span className="dsel-val">{ value || THEME_DEFAULT }</span>
1483 <span className="dsel-chev">
1484 <ChevronDownIcon />
1485 </span>
1486 </button>
1487 { open && (
1488 <div className="dsel-pop">
1489 <div className="dsel-search-wrap">
1490 <svg className="dsel-search-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
1491 <circle cx="11" cy="11" r="7" />
1492 <path d="m21 21-4.35-4.35" />
1493 </svg>
1494 <input
1495 ref={ searchRef }
1496 type="text"
1497 className="dsel-search"
1498 placeholder={ __( 'Search fonts…', 'everest-forms' ) }
1499 value={ query }
1500 aria-label={ __( 'Search fonts', 'everest-forms' ) }
1501 onChange={ ( e ) => {
1502 setQuery( e.target.value );
1503 setActive( 0 );
1504 } }
1505 onKeyDown={ onKeyDown }
1506 />
1507 </div>
1508 <div className="dsel-list" ref={ listRef } role="listbox" aria-label={ token.label }>
1509 { filtered.length === 0 ? (
1510 <div className="dsel-empty">{ __( 'No fonts found', 'everest-forms' ) }</div>
1511 ) : (
1512 filtered.map( ( o, i ) => (
1513 <button
1514 key={ o.value || '__default' }
1515 type="button"
1516 role="option"
1517 aria-selected={ o.value === value }
1518 className={
1519 'dsel-opt' +
1520 ( i === active ? ' active' : '' ) +
1521 ( o.value === value ? ' sel' : '' )
1522 }
1523 onMouseEnter={ () => setActive( i ) }
1524 onClick={ () => choose( o.value ) }
1525 >
1526 <span className="dsel-check">{ o.value === value && <CheckIcon /> }</span>
1527 { o.label }
1528 </button>
1529 ) )
1530 ) }
1531 </div>
1532 </div>
1533 ) }
1534 </div>
1535 { hint && <div className={ 'dep-hint' + ( themeFont ? ' dep-hint--font' : '' ) }>{ hint }</div> }
1536 </ControlShell>
1537 );
1538 }
1539
1540 function AlignControl( props: ControlProps ) {
1541 const { token, store } = props;
1542 const value = String( store.resolve( token.key ) );
1543 return (
1544 <ControlShell { ...props }>
1545 <div className="iconset" role="group" aria-label={ token.label }>
1546 { [ 'left', 'center', 'right' ].map( ( a ) => (
1547 <button
1548 key={ a }
1549 type="button"
1550 aria-pressed={ value === a }
1551 aria-label={ __( 'Align', 'everest-forms' ) + ' ' + a }
1552 onClick={ () => store.setTokenValue( token.key, a, false ) }
1553 >
1554 <Svg inner={ ALIGN_ICONS[ a ] } />
1555 </button>
1556 ) ) }
1557 </div>
1558 </ControlShell>
1559 );
1560 }
1561
1562 function FontStyleControl( props: ControlProps ) {
1563 const { token, store } = props;
1564 const value = ( store.resolve( token.key ) || {} ) as FontStyleValue;
1565 const weightOptions = token.weight_options || [];
1566 return (
1567 <ControlShell { ...props }>
1568 <div className="fstylewrap">
1569 { weightOptions.length > 0 && (
1570 <select
1571 className="fweight-select"
1572 aria-label={ __( 'Font weight', 'everest-forms' ) }
1573 value={ value.weight || '' }
1574 onChange={ ( e ) => {
1575 const next = clone( store.resolve( token.key ) ) as FontStyleValue;
1576 next.weight = e.target.value;
1577 store.setTokenValue( token.key, next, false );
1578 } }
1579 >
1580 { weightOptions.map( ( o ) => (
1581 <option key={ o.value } value={ o.value }>{ o.label }</option>
1582 ) ) }
1583 </select>
1584 ) }
1585 <div className="fstyleset" role="group" aria-label={ token.label }>
1586 { FONTSTYLE_BUTTONS.map( ( [ flag, glyph, title ] ) => (
1587 <button
1588 key={ flag }
1589 type="button"
1590 aria-pressed={ !! value[ flag ] }
1591 title={ title }
1592 aria-label={ title }
1593 dangerouslySetInnerHTML={ { __html: glyph } }
1594 onClick={ () => {
1595 const next = clone( store.resolve( token.key ) ) as FontStyleValue;
1596 next[ flag ] = ! next[ flag ];
1597 store.setTokenValue( token.key, next, false );
1598 } }
1599 />
1600 ) ) }
1601 </div>
1602 </div>
1603 </ControlShell>
1604 );
1605 }
1606
1607 function MediaControl( props: ControlProps ) {
1608 const { token, store } = props;
1609 const value = String( store.resolve( token.key ) || '' );
1610
1611 const openPicker = () => {
1612 const media = ( window as any ).wp?.media;
1613 if ( ! media ) {
1614 return;
1615 }
1616 const frame = media( {
1617 title: __( 'Select background image', 'everest-forms' ),
1618 multiple: false,
1619 library: { type: 'image' },
1620 } );
1621 frame.on( 'select', () => {
1622 const att = frame.state().get( 'selection' ).first().toJSON();
1623 if ( att && att.url ) {
1624 store.setTokenValue( token.key, att.url, false );
1625 }
1626 } );
1627 frame.open();
1628 };
1629
1630 return (
1631 <ControlShell { ...props }>
1632 <div className="mediactrl">
1633 <button type="button" className="mediabtn" onClick={ openPicker }>
1634 { value ? __( 'Change image', 'everest-forms' ) : __( 'Select image', 'everest-forms' ) }
1635 </button>
1636 { value && (
1637 <button
1638 type="button"
1639 className="mediaclear"
1640 aria-label={ __( 'Remove image', 'everest-forms' ) }
1641 onClick={ () => store.setTokenValue( token.key, '', false ) }
1642 >
1643 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
1644 <path d="M18 6 6 18M6 6l12 12" />
1645 </svg>
1646 </button>
1647 ) }
1648 </div>
1649 </ControlShell>
1650 );
1651 }
1652
1653 function ToggleControl( props: ControlProps ) {
1654 const { token, store } = props;
1655 // The global "Apply Theme Style" toggle forces fonts.theme on (see store.themeFont()) — disable
1656 // this switch rather than leave it clickable-but-inert, and say why.
1657 const forcedByGlobal = token.key === 'fonts.theme' && store.applyThemeStyle;
1658 const value = forcedByGlobal || store.resolve( token.key ) === true;
1659 const hint = forcedByGlobal
1660 ? __( 'Forced on by the global “Apply Theme Style” setting above.', 'everest-forms' )
1661 : '';
1662 return (
1663 <ControlShell
1664 { ...props }
1665 right={
1666 <button
1667 type="button"
1668 className="switch"
1669 role="switch"
1670 disabled={ forcedByGlobal }
1671 aria-checked={ value }
1672 aria-label={ token.label }
1673 onClick={ () => store.setTokenValue( token.key, ! value, false ) }
1674 />
1675 }
1676 >
1677 { hint && <div className="dep-hint">{ hint }</div> }
1678 </ControlShell>
1679 );
1680 }
1681
1682 export function ControlRenderer( props: ControlProps & { depHint?: string } ) {
1683 switch ( props.token.type ) {
1684 case 'slider':
1685 return <SliderControl { ...props } />;
1686 case 'color':
1687 return <ColorControl { ...props } />;
1688 case 'box4':
1689 return <Box4Control { ...props } />;
1690 case 'select':
1691 return props.token.source === 'google_fonts' ? (
1692 <FontSelectControl { ...props } />
1693 ) : (
1694 <SelectControl { ...props } />
1695 );
1696 case 'align':
1697 return <AlignControl { ...props } />;
1698 case 'fontstyle':
1699 return <FontStyleControl { ...props } />;
1700 case 'media':
1701 return <MediaControl { ...props } />;
1702 case 'toggle':
1703 return <ToggleControl { ...props } />;
1704 default:
1705 return null;
1706 }
1707 }
1708