PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.2
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.2
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / fields / form-styling.php

form-styling.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.2, at inc/fields/form-styling.php

458 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-form styling helper for donation forms.
4 *
5 * Reads the per-form styling meta and converts it into CSS custom properties
6 * applied inline on the `.sd-form-container` wrapper. Only values the user set
7 * are emitted; everything else falls back to the defaults in
8 * src/blocks/styles/_variables.scss.
9 *
10 * @package SureDonation
11 * @since 1.0.0
12 */
13
14 namespace SureDonation\Inc\Fields;
15
16 use SureDonation\Inc\Post_Types\Donation_Form;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit; // Exit if accessed directly.
20 }
21
22 /**
23 * Form_Styling class.
24 *
25 * @since 1.0.0
26 */
27 class Form_Styling {
28
29 /**
30 * Field-spacing presets. Each sets the full density variable set (matching
31 * SureForms' field-spacing scaling). "medium" mirrors the :root defaults in
32 * src/blocks/styles/_variables.scss, so it is never emitted (defaults apply).
33 *
34 * Kept in sync with SPACING_MAP in src/editor/form-style-vars.js (editor
35 * preview) — update both together.
36 *
37 * @var array<string, array<string, string>>
38 */
39 private const SPACING_MAP = [
40 'small' => [
41 '--sd-row-gap-between-blocks' => '16px',
42 '--sd-column-gap-between-blocks' => '12px',
43 '--sd-col-gap-between-fields' => '12px',
44 '--sd-input-height' => '40px',
45 '--sd-input-field-padding' => '10px 12px',
46 '--sd-input-field-font-size' => '14px',
47 '--sd-input-field-line-height' => '20px',
48 '--sd-input-field-margin-top' => '4px',
49 '--sd-input-field-margin-bottom' => '4px',
50 '--sd-label-font-size' => '14px',
51 '--sd-label-line-height' => '20px',
52 '--sd-description-font-size' => '12px',
53 '--sd-description-line-height' => '16px',
54 '--sd-btn-padding' => '8px 14px',
55 '--sd-btn-font-size' => '14px',
56 '--sd-btn-line-height' => '20px',
57 '--sd-donation-amount-vertical-padding' => '16px',
58 '--sd-donation-amount-internal-option-gap' => '8px',
59 '--sd-donation-amount-outer-padding' => '0',
60 '--sd-checkbox-size' => '16px',
61 ],
62 'large' => [
63 '--sd-row-gap-between-blocks' => '20px',
64 '--sd-column-gap-between-blocks' => '16px',
65 '--sd-col-gap-between-fields' => '16px',
66 '--sd-input-height' => '48px',
67 '--sd-input-field-padding' => '10px 14px',
68 '--sd-input-field-font-size' => '18px',
69 '--sd-input-field-line-height' => '28px',
70 '--sd-input-field-margin-top' => '8px',
71 '--sd-input-field-margin-bottom' => '8px',
72 '--sd-label-font-size' => '18px',
73 '--sd-label-line-height' => '28px',
74 '--sd-description-font-size' => '16px',
75 '--sd-description-line-height' => '24px',
76 '--sd-btn-padding' => '10px 14px',
77 '--sd-btn-font-size' => '18px',
78 '--sd-btn-line-height' => '28px',
79 '--sd-donation-amount-vertical-padding' => '24px',
80 '--sd-donation-amount-internal-option-gap' => '12px',
81 '--sd-donation-amount-outer-padding' => '4px',
82 '--sd-checkbox-size' => '20px',
83 ],
84 ];
85
86 /**
87 * Default settings (also the editor panel defaults).
88 *
89 * @return array<string, mixed>
90 * @since 1.0.0
91 */
92 public static function get_defaults() {
93 return [
94 'bgType' => 'color',
95 'bgColor' => '',
96 'bgGradient' => 'linear-gradient(90deg,#FFC9B2 0%,#C7CBFF 100%)',
97 'bgImage' => '',
98 'bgImageId' => 0,
99 'bgImageSize' => 'cover',
100 'bgImagePosition' => 'center center',
101 'bgImageRepeat' => 'no-repeat',
102 // Colors default to empty here so unset values fall through to the
103 // :root defaults in _variables.scss (the editor's STYLE_DEFAULTS seeds
104 // the actual hex values instead, only to populate the panel swatches).
105 'primaryColor' => '',
106 'textColor' => '',
107 'textOnPrimaryColor' => '',
108 'padding' => [
109 'top' => '',
110 'right' => '',
111 'bottom' => '',
112 'left' => '',
113 ],
114 'borderRadius' => [
115 'top' => '',
116 'right' => '',
117 'bottom' => '',
118 'left' => '',
119 ],
120 'fieldSpacing' => 'medium',
121 'buttonAlignment' => 'justify',
122 ];
123 }
124
125 /**
126 * Read + merge the per-form styling settings.
127 *
128 * @param int $form_id Form post ID.
129 * @return array<string, mixed>
130 * @since 1.0.0
131 */
132 public static function get_settings( $form_id ) {
133 $defaults = self::get_defaults();
134 $raw = get_post_meta( (int) $form_id, Donation_Form::META_STYLING, true );
135
136 if ( ! is_string( $raw ) || '' === $raw ) {
137 return $defaults;
138 }
139
140 $decoded = json_decode( $raw, true );
141 if ( ! is_array( $decoded ) ) {
142 return $defaults;
143 }
144
145 $settings = array_merge( $defaults, $decoded );
146 $settings['padding'] = array_merge( $defaults['padding'], is_array( $decoded['padding'] ?? null ) ? $decoded['padding'] : [] );
147 $settings['borderRadius'] = array_merge( $defaults['borderRadius'], is_array( $decoded['borderRadius'] ?? null ) ? $decoded['borderRadius'] : [] );
148
149 return $settings;
150 }
151
152 /**
153 * Sanitize the styling meta JSON on save.
154 *
155 * @param mixed $value Raw meta value (JSON string).
156 * @return string Sanitized JSON string ('' when invalid/empty).
157 * @since 1.0.0
158 */
159 public static function sanitize_json( $value ) {
160 if ( ! is_string( $value ) || '' === trim( $value ) ) {
161 return '';
162 }
163
164 $decoded = json_decode( $value, true );
165 if ( ! is_array( $decoded ) ) {
166 return '';
167 }
168
169 $defaults = self::get_defaults();
170 $clean = [];
171
172 $clean['bgType'] = in_array( $decoded['bgType'] ?? '', [ 'color', 'gradient', 'image' ], true ) ? $decoded['bgType'] : 'color';
173 $clean['bgColor'] = self::sanitize_color( $decoded['bgColor'] ?? '' );
174 $clean['bgGradient'] = self::sanitize_gradient( $decoded['bgGradient'] ?? '' );
175 // Strip quotes/parens so the URL can't break out of the url('...') wrap.
176 // The front-end render resolves the image from bgImageId instead.
177 $clean['bgImage'] = str_replace( [ "'", '"', '(', ')' ], '', esc_url_raw( (string) ( $decoded['bgImage'] ?? '' ) ) );
178 $clean['bgImageId'] = absint( $decoded['bgImageId'] ?? 0 );
179 $clean['bgImageSize'] = in_array( $decoded['bgImageSize'] ?? '', [ 'cover', 'contain', 'auto' ], true ) ? $decoded['bgImageSize'] : 'cover';
180 $clean['bgImageRepeat'] = in_array( $decoded['bgImageRepeat'] ?? '', [ 'no-repeat', 'repeat', 'repeat-x', 'repeat-y' ], true ) ? $decoded['bgImageRepeat'] : 'no-repeat';
181 // Position has no UI control; allow safe keyword/percentage values only.
182 $clean['bgImagePosition'] = preg_match( '/^[a-z0-9%.\s]+$/i', (string) ( $decoded['bgImagePosition'] ?? '' ) ) ? trim( (string) $decoded['bgImagePosition'] ) : 'center center';
183 $clean['primaryColor'] = self::sanitize_color( $decoded['primaryColor'] ?? '' );
184 $clean['textColor'] = self::sanitize_color( $decoded['textColor'] ?? '' );
185 $clean['textOnPrimaryColor'] = self::sanitize_color( $decoded['textOnPrimaryColor'] ?? '' );
186 $clean['padding'] = self::sanitize_box( $decoded['padding'] ?? [], $defaults['padding'] );
187 $clean['borderRadius'] = self::sanitize_box( $decoded['borderRadius'] ?? [], $defaults['borderRadius'] );
188 $clean['fieldSpacing'] = in_array( $decoded['fieldSpacing'] ?? '', [ 'small', 'medium', 'large' ], true ) ? $decoded['fieldSpacing'] : 'medium';
189 $clean['buttonAlignment'] = in_array( $decoded['buttonAlignment'] ?? '', [ 'left', 'center', 'right', 'justify' ], true ) ? $decoded['buttonAlignment'] : 'justify';
190
191 $encoded = wp_json_encode( $clean );
192 return is_string( $encoded ) ? $encoded : '';
193 }
194
195 /**
196 * Build the inline CSS custom-property string for the form wrapper.
197 *
198 * Returns the CSS declarations only (no surrounding style attribute). The
199 * caller is expected to output the result via esc_attr() inside a style
200 * attribute, which the browser HTML-decodes before the CSS parser runs.
201 *
202 * @param int $form_id Form post ID.
203 * @return string CSS declarations, or '' when nothing is customized.
204 * @since 1.0.0
205 */
206 public static function get_style_attr( $form_id ) {
207 $settings = self::get_settings( $form_id );
208 $vars = [];
209
210 // Colors. Color-derived tints are emitted per-form so they track the
211 // chosen color: the static :root @supports defaults in _variables.scss are
212 // computed from the default brand/text colors and cannot see a per-form
213 // override (they live on :root, the override on the form container), so
214 // without this the button hover and field tints stay default. Ratios
215 // mirror that @supports block and SureForms' inc/generate-form-markup.php.
216 // Keep in sync with buildStyleVars() in src/editor/form-style-vars.js.
217 if ( '' !== $settings['primaryColor'] ) {
218 $primary = $settings['primaryColor'];
219 $vars['--sd-color-scheme-primary'] = $primary;
220 $vars['--sd-color-scheme-primary-hover'] = "hsl(from {$primary} h s l / 0.9)";
221 $vars['--sd-color-input-border-hover'] = "hsl(from {$primary} h s l / 0.65)";
222 $vars['--sd-color-input-border-focus-glow'] = "hsl(from {$primary} h s l / 0.15)";
223 $vars['--sd-color-input-selected'] = "hsl(from {$primary} h s l / 0.1)";
224 }
225 if ( '' !== $settings['textColor'] ) {
226 $text = $settings['textColor'];
227 $vars['--sd-color-input-text'] = $text;
228 $vars['--sd-color-input-label'] = $text;
229 $vars['--sd-color-input-description'] = "hsl(from {$text} h s l / 0.65)";
230 $vars['--sd-color-input-placeholder'] = "hsl(from {$text} h s l / 0.5)";
231 $vars['--sd-color-input-background'] = "hsl(from {$text} h s l / 0.02)";
232 $vars['--sd-color-input-background-hover'] = "hsl(from {$text} h s l / 0.05)";
233 $vars['--sd-color-input-border'] = "hsl(from {$text} h s l / 0.25)";
234 $vars['--sd-color-donation-amount-svg'] = "hsl(from {$text} h s l / 0.7)";
235 $vars['--sd-color-input-prefix'] = "hsl(from {$text} h s l / 0.65)";
236 $vars['--sd-disabled-color'] = "hsl(from {$text} h s l / 0.5)";
237 $vars['--sd-disabled-background-color'] = "hsl(from {$text} h s l / 0.07)";
238 $vars['--sd-disabled-border'] = "hsl(from {$text} h s l / 0.15)";
239 }
240 if ( '' !== $settings['textOnPrimaryColor'] ) {
241 $vars['--sd-btn-text-color'] = $settings['textOnPrimaryColor'];
242 }
243
244 // Background.
245 $background = self::background_value( $settings );
246 if ( '' !== $background ) {
247 $vars['--sd-form-background'] = $background;
248 }
249
250 // Padding / border radius.
251 $padding = self::box_value( $settings['padding'] );
252 if ( '' !== $padding ) {
253 $vars['--sd-form-padding'] = $padding;
254 }
255 $radius = self::box_value( $settings['borderRadius'] );
256 if ( '' !== $radius ) {
257 $vars['--sd-form-border-radius'] = $radius;
258 }
259
260 // Field spacing — the full density set; "medium" matches _variables.scss
261 // defaults, so it is skipped.
262 if ( 'medium' !== $settings['fieldSpacing'] && isset( self::SPACING_MAP[ $settings['fieldSpacing'] ] ) ) {
263 foreach ( self::SPACING_MAP[ $settings['fieldSpacing'] ] as $name => $value ) {
264 $vars[ $name ] = $value;
265 }
266 }
267
268 // Button alignment — skip the default ('justify'); CSS fallback applies.
269 $align_map = [
270 'left' => 'flex-start',
271 'center' => 'center',
272 'right' => 'flex-end',
273 ];
274 if ( isset( $align_map[ $settings['buttonAlignment'] ] ) ) {
275 $vars['--sd-btn-align-items'] = $align_map[ $settings['buttonAlignment'] ];
276 $vars['--sd-btn-width'] = 'auto';
277 }
278
279 if ( empty( $vars ) ) {
280 return '';
281 }
282
283 $declarations = [];
284 foreach ( $vars as $name => $value ) {
285 $declarations[] = $name . ':' . $value;
286 }
287
288 return implode( ';', $declarations ) . ';';
289 }
290
291 /**
292 * Build the `background` shorthand value from the settings.
293 *
294 * @param array<string, mixed> $settings Merged settings.
295 * @return string
296 * @since 1.0.0
297 */
298 private static function background_value( $settings ) {
299 switch ( $settings['bgType'] ) {
300 case 'gradient':
301 return '' !== $settings['bgGradient'] ? $settings['bgGradient'] : '';
302 case 'image':
303 // Resolve the URL from the attachment ID (trusted, from the media
304 // library) rather than the stored URL string; fall back to the
305 // sanitized stored URL only if the attachment can't be resolved.
306 $image_url = ! empty( $settings['bgImageId'] )
307 ? wp_get_attachment_image_url( (int) $settings['bgImageId'], 'full' )
308 : '';
309 if ( empty( $image_url ) ) {
310 $image_url = $settings['bgImage'];
311 }
312 if ( '' === $image_url ) {
313 return '';
314 }
315 // Guard the shorthand parts so an empty value can't make it invalid.
316 $position = '' !== $settings['bgImagePosition'] ? $settings['bgImagePosition'] : 'center center';
317 $size = '' !== $settings['bgImageSize'] ? $settings['bgImageSize'] : 'cover';
318 $repeat = '' !== $settings['bgImageRepeat'] ? $settings['bgImageRepeat'] : 'no-repeat';
319 return sprintf(
320 "url('%s') %s / %s %s",
321 esc_url_raw( $image_url ),
322 $position,
323 $size,
324 $repeat
325 );
326 case 'color':
327 default:
328 return '' !== $settings['bgColor'] ? $settings['bgColor'] : '';
329 }
330 }
331
332 /**
333 * Build a 4-side CSS shorthand (e.g. padding) from a box setting.
334 *
335 * @param array<string, mixed> $box Box setting (top/right/bottom/left/unit).
336 * @return string Shorthand value, or '' when no side is set.
337 * @since 1.0.0
338 */
339 private static function box_value( $box ) {
340 $any = false;
341 $parts = [];
342
343 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
344 $length = self::sanitize_length( $box[ $side ] ?? '' );
345 if ( '' !== $length ) {
346 $any = true;
347 }
348 $parts[] = '' !== $length ? $length : '0';
349 }
350
351 return $any ? implode( ' ', $parts ) : '';
352 }
353
354 /**
355 * Sanitize a box setting for storage (per-side CSS lengths).
356 *
357 * @param mixed $box Incoming box value.
358 * @param array<string, mixed> $fallback Default box.
359 * @return array<string, mixed>
360 * @since 1.0.0
361 */
362 private static function sanitize_box( $box, $fallback ) {
363 if ( ! is_array( $box ) ) {
364 return $fallback;
365 }
366
367 $clean = [];
368 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
369 $clean[ $side ] = self::sanitize_length( $box[ $side ] ?? '' );
370 }
371
372 return $clean;
373 }
374
375 /**
376 * Validate a CSS length (e.g. "10px", "1.5rem"); bare numbers become px.
377 *
378 * @param mixed $value Incoming value.
379 * @return string Valid length, or '' when invalid/empty.
380 * @since 1.0.0
381 */
382 private static function sanitize_length( $value ) {
383 if ( is_numeric( $value ) ) {
384 return ( 0 + $value ) . 'px';
385 }
386 $value = is_string( $value ) ? trim( $value ) : '';
387 if ( '' === $value ) {
388 return '';
389 }
390 return preg_match( '/^-?\d*\.?\d+(px|em|rem|%|vw|vh)$/', $value ) ? $value : '';
391 }
392
393 /**
394 * Sanitize a color value via a strict allowlist.
395 *
396 * Accepts hex, rgb()/rgba()/hsl()/hsla() with numeric arguments only, or a
397 * bare named color. Anything else (e.g. "red url(https://…)") is rejected so
398 * a color field cannot smuggle an external resource into the inline style.
399 *
400 * @param mixed $value Incoming color.
401 * @return string Valid color, or '' when invalid/empty.
402 * @since 1.0.0
403 */
404 private static function sanitize_color( $value ) {
405 $value = is_string( $value ) ? trim( $value ) : '';
406 if ( '' === $value ) {
407 return '';
408 }
409 // Hex: #rgb / #rgba / #rrggbb / #rrggbbaa.
410 if ( preg_match( '/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/', $value ) ) {
411 return $value;
412 }
413 // Functional notation with numeric arguments only (no nested functions).
414 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\(\s*[0-9.,%\/\s]+\)$/i', $value ) ) {
415 return $value;
416 }
417 // Named color — letters only, so it cannot contain parens/url()/escapes.
418 if ( preg_match( '/^[a-z]+$/i', $value ) ) {
419 return $value;
420 }
421 // CSS custom-property reference for theme/global palette colors, e.g.
422 // var(--wp--preset--color--primary), with an optional safe fallback
423 // (hex / named / numeric rgb()|hsl() / one nested var). The property name
424 // is restricted to [A-Za-z0-9_-] and the whole value is anchored, so it
425 // cannot contain quotes, semicolons, url() or escapes that would break out
426 // of the inline style attribute.
427 if ( preg_match( '/^var\(\s*--[A-Za-z0-9_-]+\s*(,\s*(#[A-Fa-f0-9]{3,8}|[A-Za-z]+|(?:rgb|rgba|hsl|hsla)\([0-9.,%\/\s]+\)|var\(\s*--[A-Za-z0-9_-]+\s*\)))?\s*\)$/i', $value ) ) {
428 return $value;
429 }
430 return '';
431 }
432
433 /**
434 * Sanitize a CSS gradient value.
435 *
436 * Requires a (repeating-)?(linear|radial|conic)-gradient(…) shape and rejects
437 * url(), at-rules, and declaration-breaking characters, so the gradient field
438 * cannot reference an external resource or escape the inline style.
439 *
440 * @param mixed $value Incoming gradient.
441 * @return string Valid gradient, or '' when invalid/empty.
442 * @since 1.0.0
443 */
444 private static function sanitize_gradient( $value ) {
445 $value = is_string( $value ) ? trim( $value ) : '';
446 if ( '' === $value ) {
447 return '';
448 }
449 if ( preg_match( '/url\s*\(|@|[;{}<>"\'\\\\]/i', $value ) ) {
450 return '';
451 }
452 if ( ! preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/i', $value ) ) {
453 return '';
454 }
455 return $value;
456 }
457 }
458