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 |