PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
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 trunk, at inc/fields/form-styling.php

539 lines 20.6 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 // When true the form renders without the SureDonation stylesheet and
123 // inline CSS variables so the site's own CSS fully controls its
124 // appearance (mirrors SureForms' disable_default_styles).
125 'disable_default_styles' => false,
126 ];
127 }
128
129 /**
130 * Read + merge the per-form styling settings.
131 *
132 * @param int $form_id Form post ID.
133 * @return array<string, mixed>
134 * @since 1.0.0
135 */
136 public static function get_settings( $form_id ) {
137 $defaults = self::get_defaults();
138 $raw = get_post_meta( (int) $form_id, Donation_Form::META_STYLING, true );
139
140 if ( ! is_string( $raw ) || '' === $raw ) {
141 return $defaults;
142 }
143
144 $decoded = json_decode( $raw, true );
145 if ( ! is_array( $decoded ) ) {
146 return $defaults;
147 }
148
149 $settings = array_merge( $defaults, $decoded );
150 $settings['padding'] = array_merge( $defaults['padding'], is_array( $decoded['padding'] ?? null ) ? $decoded['padding'] : [] );
151 $settings['borderRadius'] = array_merge( $defaults['borderRadius'], is_array( $decoded['borderRadius'] ?? null ) ? $decoded['borderRadius'] : [] );
152
153 return $settings;
154 }
155
156 /**
157 * Sanitize the styling meta JSON on save.
158 *
159 * @param mixed $value Raw meta value (JSON string).
160 * @return string Sanitized JSON string ('' when invalid/empty).
161 * @since 1.0.0
162 */
163 public static function sanitize_json( $value ) {
164 if ( ! is_string( $value ) || '' === trim( $value ) ) {
165 return '';
166 }
167
168 $decoded = json_decode( $value, true );
169 if ( ! is_array( $decoded ) ) {
170 return '';
171 }
172
173 $defaults = self::get_defaults();
174 $clean = [];
175
176 $clean['bgType'] = in_array( $decoded['bgType'] ?? '', [ 'color', 'gradient', 'image' ], true ) ? $decoded['bgType'] : 'color';
177 $clean['bgColor'] = self::sanitize_color( $decoded['bgColor'] ?? '' );
178 $clean['bgGradient'] = self::sanitize_gradient( $decoded['bgGradient'] ?? '' );
179 // Strip quotes/parens so the URL can't break out of the url('...') wrap.
180 // The front-end render resolves the image from bgImageId instead.
181 $clean['bgImage'] = str_replace( [ "'", '"', '(', ')' ], '', esc_url_raw( (string) ( $decoded['bgImage'] ?? '' ) ) );
182 $clean['bgImageId'] = absint( $decoded['bgImageId'] ?? 0 );
183 $clean['bgImageSize'] = in_array( $decoded['bgImageSize'] ?? '', [ 'cover', 'contain', 'auto' ], true ) ? $decoded['bgImageSize'] : 'cover';
184 $clean['bgImageRepeat'] = in_array( $decoded['bgImageRepeat'] ?? '', [ 'no-repeat', 'repeat', 'repeat-x', 'repeat-y' ], true ) ? $decoded['bgImageRepeat'] : 'no-repeat';
185 // Position has no UI control; allow safe keyword/percentage values only.
186 $clean['bgImagePosition'] = preg_match( '/^[a-z0-9%.\s]+$/i', (string) ( $decoded['bgImagePosition'] ?? '' ) ) ? trim( (string) $decoded['bgImagePosition'] ) : 'center center';
187 $clean['primaryColor'] = self::sanitize_color( $decoded['primaryColor'] ?? '' );
188 $clean['textColor'] = self::sanitize_color( $decoded['textColor'] ?? '' );
189 $clean['textOnPrimaryColor'] = self::sanitize_color( $decoded['textOnPrimaryColor'] ?? '' );
190 $clean['padding'] = self::sanitize_box( $decoded['padding'] ?? [], $defaults['padding'] );
191 $clean['borderRadius'] = self::sanitize_box( $decoded['borderRadius'] ?? [], $defaults['borderRadius'] );
192 $clean['fieldSpacing'] = in_array( $decoded['fieldSpacing'] ?? '', [ 'small', 'medium', 'large' ], true ) ? $decoded['fieldSpacing'] : 'medium';
193 $clean['buttonAlignment'] = in_array( $decoded['buttonAlignment'] ?? '', [ 'left', 'center', 'right', 'justify' ], true ) ? $decoded['buttonAlignment'] : 'justify';
194
195 // Boolean flag, not a style value — must survive sanitization or an
196 // editor save silently re-enables the default styling.
197 $clean['disable_default_styles'] = ! empty( $decoded['disable_default_styles'] );
198
199 $encoded = wp_json_encode( $clean );
200 return is_string( $encoded ) ? $encoded : '';
201 }
202
203 /**
204 * Check whether the form renders without SureDonation's default styling.
205 *
206 * When enabled the frontend stylesheet is not enqueued for the form and the
207 * inline CSS-variable style attribute is omitted, so the site's own CSS
208 * fully controls the form's appearance. The container is stamped with an
209 * `sd-styling-none` marker class so custom CSS can target the state.
210 *
211 * @param int $form_id Form post ID.
212 * @return bool True when default styling is disabled for the form.
213 * @since 1.4.0
214 */
215 public static function is_default_styling_disabled( $form_id ) {
216 $form_id = absint( $form_id );
217 if ( ! $form_id ) {
218 return false;
219 }
220
221 $settings = self::get_settings( $form_id );
222 $disabled = ! empty( $settings['disable_default_styles'] );
223
224 /**
225 * Filters whether SureDonation's default frontend styling is disabled for a form.
226 *
227 * Lets themes/plugins toggle the unstyled mode programmatically, overriding
228 * the stored per-form meta. Return true to render the form without the
229 * SureDonation stylesheet and inline CSS variables.
230 *
231 * @param bool $disabled Whether default styling is disabled (from meta).
232 * @param int $form_id Form post ID.
233 * @since 1.4.0
234 */
235 return (bool) apply_filters( 'suredonation_disable_default_styles', $disabled, $form_id );
236 }
237
238 /**
239 * Build the inline CSS custom-property string for the form wrapper.
240 *
241 * Returns the CSS declarations only (no surrounding style attribute). The
242 * caller is expected to output the result via esc_attr() inside a style
243 * attribute, which the browser HTML-decodes before the CSS parser runs.
244 *
245 * @param int $form_id Form post ID.
246 * @return string CSS declarations, or '' when nothing is customized.
247 * @since 1.0.0
248 */
249 public static function get_style_attr( $form_id ) {
250 // Unstyled mode: no inline CSS variables either — an inline style on the
251 // container would override any site/custom CSS that themes the form.
252 if ( self::is_default_styling_disabled( $form_id ) ) {
253 return '';
254 }
255
256 $settings = self::get_settings( $form_id );
257 $vars = [];
258
259 // Colors. Color-derived tints are emitted per-form so they track the
260 // chosen color: the static :root @supports defaults in _variables.scss are
261 // computed from the default brand/text colors and cannot see a per-form
262 // override (they live on :root, the override on the form container), so
263 // without this the button hover and field tints stay default. Ratios
264 // mirror that @supports block and SureForms' inc/generate-form-markup.php.
265 // Keep in sync with buildStyleVars() in src/editor/form-style-vars.js.
266 if ( '' !== $settings['primaryColor'] ) {
267 $primary = $settings['primaryColor'];
268 $vars['--sd-color-scheme-primary'] = $primary;
269 $vars['--sd-color-scheme-primary-hover'] = "hsl(from {$primary} h s l / 0.9)";
270 $vars['--sd-color-input-border-hover'] = "hsl(from {$primary} h s l / 0.65)";
271 $vars['--sd-color-input-border-focus-glow'] = "hsl(from {$primary} h s l / 0.15)";
272 $vars['--sd-color-input-selected'] = "hsl(from {$primary} h s l / 0.1)";
273 }
274 if ( '' !== $settings['textColor'] ) {
275 $text = $settings['textColor'];
276 $vars['--sd-color-input-text'] = $text;
277 $vars['--sd-color-input-label'] = $text;
278 $vars['--sd-color-input-description'] = "hsl(from {$text} h s l / 0.65)";
279 $vars['--sd-color-input-placeholder'] = "hsl(from {$text} h s l / 0.5)";
280 $vars['--sd-color-input-background'] = "hsl(from {$text} h s l / 0.02)";
281 $vars['--sd-color-input-background-hover'] = "hsl(from {$text} h s l / 0.05)";
282 $vars['--sd-color-input-border'] = "hsl(from {$text} h s l / 0.25)";
283 $vars['--sd-color-donation-amount-svg'] = "hsl(from {$text} h s l / 0.7)";
284 $vars['--sd-color-input-prefix'] = "hsl(from {$text} h s l / 0.65)";
285 $vars['--sd-disabled-color'] = "hsl(from {$text} h s l / 0.5)";
286 $vars['--sd-disabled-background-color'] = "hsl(from {$text} h s l / 0.07)";
287 $vars['--sd-disabled-border'] = "hsl(from {$text} h s l / 0.15)";
288 }
289 if ( '' !== $settings['textOnPrimaryColor'] ) {
290 $vars['--sd-btn-text-color'] = $settings['textOnPrimaryColor'];
291 }
292
293 // Background.
294 $background = self::background_value( $settings );
295 if ( '' !== $background ) {
296 $vars['--sd-form-background'] = $background;
297 }
298
299 // Padding / border radius.
300 $padding = self::box_value( $settings['padding'] );
301 if ( '' !== $padding ) {
302 $vars['--sd-form-padding'] = $padding;
303 }
304 $radius = self::box_value( $settings['borderRadius'] );
305 if ( '' !== $radius ) {
306 $vars['--sd-form-border-radius'] = $radius;
307 }
308
309 // Field spacing — the full density set; "medium" matches _variables.scss
310 // defaults, so it is skipped.
311 if ( 'medium' !== $settings['fieldSpacing'] && isset( self::SPACING_MAP[ $settings['fieldSpacing'] ] ) ) {
312 foreach ( self::SPACING_MAP[ $settings['fieldSpacing'] ] as $name => $value ) {
313 $vars[ $name ] = $value;
314 }
315 }
316
317 // Button alignment — skip the default ('justify'); CSS fallback applies.
318 $align_map = [
319 'left' => 'flex-start',
320 'center' => 'center',
321 'right' => 'flex-end',
322 ];
323 if ( isset( $align_map[ $settings['buttonAlignment'] ] ) ) {
324 $vars['--sd-btn-align-items'] = $align_map[ $settings['buttonAlignment'] ];
325 $vars['--sd-btn-width'] = 'auto';
326 }
327
328 /**
329 * Filter the form style CSS custom properties before they are serialized
330 * onto the `.sd-form-container` wrapper.
331 *
332 * Add-ons (e.g. SureDonation Pro) use this to contribute additional
333 * `--sd-*` variables. Runs before the empty-check so an add-on can style a
334 * form even when the free panel set nothing. Values must be pre-sanitized
335 * CSS tokens — they are emitted verbatim inside the inline style attribute.
336 *
337 * @param array<string, string> $vars Map of `--sd-*` variable => value.
338 * @param int $form_id Form post ID.
339 * @param array<string, mixed> $settings Merged free style settings.
340 * @since 1.5.0
341 */
342 $vars = apply_filters( 'suredonation_form_style_vars', $vars, (int) $form_id, $settings );
343
344 if ( ! is_array( $vars ) || empty( $vars ) ) {
345 return '';
346 }
347
348 $declarations = [];
349 foreach ( $vars as $name => $value ) {
350 // Defense-in-depth for the public filter above: only emit custom
351 // properties with scalar, declaration-safe values, so a
352 // non-sanitizing add-on callback cannot append arbitrary
353 // declarations or trigger array-to-string notices. Values are
354 // additionally escaped by the caller via esc_attr().
355 if (
356 ! is_scalar( $value )
357 || ! preg_match( '/^--[A-Za-z0-9_-]+$/', (string) $name )
358 || preg_match( '/[;{}]/', (string) $value )
359 ) {
360 continue;
361 }
362 $declarations[] = $name . ':' . $value;
363 }
364
365 return implode( ';', $declarations ) . ';';
366 }
367
368 /**
369 * Build the `background` shorthand value from the settings.
370 *
371 * @param array<string, mixed> $settings Merged settings.
372 * @return string
373 * @since 1.0.0
374 */
375 private static function background_value( $settings ) {
376 switch ( $settings['bgType'] ) {
377 case 'gradient':
378 return '' !== $settings['bgGradient'] ? $settings['bgGradient'] : '';
379 case 'image':
380 // Resolve the URL from the attachment ID (trusted, from the media
381 // library) rather than the stored URL string; fall back to the
382 // sanitized stored URL only if the attachment can't be resolved.
383 $image_url = ! empty( $settings['bgImageId'] )
384 ? wp_get_attachment_image_url( (int) $settings['bgImageId'], 'full' )
385 : '';
386 if ( empty( $image_url ) ) {
387 $image_url = $settings['bgImage'];
388 }
389 if ( '' === $image_url ) {
390 return '';
391 }
392 // Guard the shorthand parts so an empty value can't make it invalid.
393 $position = '' !== $settings['bgImagePosition'] ? $settings['bgImagePosition'] : 'center center';
394 $size = '' !== $settings['bgImageSize'] ? $settings['bgImageSize'] : 'cover';
395 $repeat = '' !== $settings['bgImageRepeat'] ? $settings['bgImageRepeat'] : 'no-repeat';
396 return sprintf(
397 "url('%s') %s / %s %s",
398 esc_url_raw( $image_url ),
399 $position,
400 $size,
401 $repeat
402 );
403 case 'color':
404 default:
405 return '' !== $settings['bgColor'] ? $settings['bgColor'] : '';
406 }
407 }
408
409 /**
410 * Build a 4-side CSS shorthand (e.g. padding) from a box setting.
411 *
412 * @param array<string, mixed> $box Box setting (top/right/bottom/left/unit).
413 * @return string Shorthand value, or '' when no side is set.
414 * @since 1.0.0
415 */
416 private static function box_value( $box ) {
417 $any = false;
418 $parts = [];
419
420 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
421 $length = self::sanitize_length( $box[ $side ] ?? '' );
422 if ( '' !== $length ) {
423 $any = true;
424 }
425 $parts[] = '' !== $length ? $length : '0';
426 }
427
428 return $any ? implode( ' ', $parts ) : '';
429 }
430
431 /**
432 * Sanitize a box setting for storage (per-side CSS lengths).
433 *
434 * @param mixed $box Incoming box value.
435 * @param array<string, mixed> $fallback Default box.
436 * @return array<string, mixed>
437 * @since 1.0.0
438 */
439 public static function sanitize_box( $box, $fallback ) {
440 if ( ! is_array( $box ) ) {
441 return $fallback;
442 }
443
444 $clean = [];
445 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
446 $clean[ $side ] = self::sanitize_length( $box[ $side ] ?? '' );
447 }
448
449 return $clean;
450 }
451
452 /**
453 * Validate a CSS length (e.g. "10px", "1.5rem"); bare numbers become px.
454 *
455 * Negative values are rejected: every consumer here (padding, border
456 * radius) is invalid with a negative length, which the browser would
457 * silently drop.
458 *
459 * @param mixed $value Incoming value.
460 * @return string Valid length, or '' when invalid/empty/negative.
461 * @since 1.0.0
462 */
463 public static function sanitize_length( $value ) {
464 if ( is_numeric( $value ) ) {
465 return $value < 0 ? '' : ( 0 + $value ) . 'px';
466 }
467 $value = is_string( $value ) ? trim( $value ) : '';
468 if ( '' === $value ) {
469 return '';
470 }
471 return preg_match( '/^\d*\.?\d+(px|em|rem|%|vw|vh)$/', $value ) ? $value : '';
472 }
473
474 /**
475 * Sanitize a color value via a strict allowlist.
476 *
477 * Accepts hex, rgb()/rgba()/hsl()/hsla() with numeric arguments only, or a
478 * bare named color. Anything else (e.g. "red url(https://…)") is rejected so
479 * a color field cannot smuggle an external resource into the inline style.
480 *
481 * @param mixed $value Incoming color.
482 * @return string Valid color, or '' when invalid/empty.
483 * @since 1.0.0
484 */
485 public static function sanitize_color( $value ) {
486 $value = is_string( $value ) ? trim( $value ) : '';
487 if ( '' === $value ) {
488 return '';
489 }
490 // Hex: #rgb / #rgba / #rrggbb / #rrggbbaa.
491 if ( preg_match( '/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/', $value ) ) {
492 return $value;
493 }
494 // Functional notation with numeric arguments only (no nested functions).
495 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\(\s*[0-9.,%\/\s]+\)$/i', $value ) ) {
496 return $value;
497 }
498 // Named color — letters only, so it cannot contain parens/url()/escapes.
499 if ( preg_match( '/^[a-z]+$/i', $value ) ) {
500 return $value;
501 }
502 // CSS custom-property reference for theme/global palette colors, e.g.
503 // var(--wp--preset--color--primary), with an optional safe fallback
504 // (hex / named / numeric rgb()|hsl() / one nested var). The property name
505 // is restricted to [A-Za-z0-9_-] and the whole value is anchored, so it
506 // cannot contain quotes, semicolons, url() or escapes that would break out
507 // of the inline style attribute.
508 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 ) ) {
509 return $value;
510 }
511 return '';
512 }
513
514 /**
515 * Sanitize a CSS gradient value.
516 *
517 * Requires a (repeating-)?(linear|radial|conic)-gradient(…) shape and rejects
518 * url(), at-rules, and declaration-breaking characters, so the gradient field
519 * cannot reference an external resource or escape the inline style.
520 *
521 * @param mixed $value Incoming gradient.
522 * @return string Valid gradient, or '' when invalid/empty.
523 * @since 1.0.0
524 */
525 public static function sanitize_gradient( $value ) {
526 $value = is_string( $value ) ? trim( $value ) : '';
527 if ( '' === $value ) {
528 return '';
529 }
530 if ( preg_match( '/url\s*\(|@|[;{}<>"\'\\\\]/i', $value ) ) {
531 return '';
532 }
533 if ( ! preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/i', $value ) ) {
534 return '';
535 }
536 return $value;
537 }
538 }
539