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

507 lines 19.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 // 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 if ( empty( $vars ) ) {
329 return '';
330 }
331
332 $declarations = [];
333 foreach ( $vars as $name => $value ) {
334 $declarations[] = $name . ':' . $value;
335 }
336
337 return implode( ';', $declarations ) . ';';
338 }
339
340 /**
341 * Build the `background` shorthand value from the settings.
342 *
343 * @param array<string, mixed> $settings Merged settings.
344 * @return string
345 * @since 1.0.0
346 */
347 private static function background_value( $settings ) {
348 switch ( $settings['bgType'] ) {
349 case 'gradient':
350 return '' !== $settings['bgGradient'] ? $settings['bgGradient'] : '';
351 case 'image':
352 // Resolve the URL from the attachment ID (trusted, from the media
353 // library) rather than the stored URL string; fall back to the
354 // sanitized stored URL only if the attachment can't be resolved.
355 $image_url = ! empty( $settings['bgImageId'] )
356 ? wp_get_attachment_image_url( (int) $settings['bgImageId'], 'full' )
357 : '';
358 if ( empty( $image_url ) ) {
359 $image_url = $settings['bgImage'];
360 }
361 if ( '' === $image_url ) {
362 return '';
363 }
364 // Guard the shorthand parts so an empty value can't make it invalid.
365 $position = '' !== $settings['bgImagePosition'] ? $settings['bgImagePosition'] : 'center center';
366 $size = '' !== $settings['bgImageSize'] ? $settings['bgImageSize'] : 'cover';
367 $repeat = '' !== $settings['bgImageRepeat'] ? $settings['bgImageRepeat'] : 'no-repeat';
368 return sprintf(
369 "url('%s') %s / %s %s",
370 esc_url_raw( $image_url ),
371 $position,
372 $size,
373 $repeat
374 );
375 case 'color':
376 default:
377 return '' !== $settings['bgColor'] ? $settings['bgColor'] : '';
378 }
379 }
380
381 /**
382 * Build a 4-side CSS shorthand (e.g. padding) from a box setting.
383 *
384 * @param array<string, mixed> $box Box setting (top/right/bottom/left/unit).
385 * @return string Shorthand value, or '' when no side is set.
386 * @since 1.0.0
387 */
388 private static function box_value( $box ) {
389 $any = false;
390 $parts = [];
391
392 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
393 $length = self::sanitize_length( $box[ $side ] ?? '' );
394 if ( '' !== $length ) {
395 $any = true;
396 }
397 $parts[] = '' !== $length ? $length : '0';
398 }
399
400 return $any ? implode( ' ', $parts ) : '';
401 }
402
403 /**
404 * Sanitize a box setting for storage (per-side CSS lengths).
405 *
406 * @param mixed $box Incoming box value.
407 * @param array<string, mixed> $fallback Default box.
408 * @return array<string, mixed>
409 * @since 1.0.0
410 */
411 private static function sanitize_box( $box, $fallback ) {
412 if ( ! is_array( $box ) ) {
413 return $fallback;
414 }
415
416 $clean = [];
417 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
418 $clean[ $side ] = self::sanitize_length( $box[ $side ] ?? '' );
419 }
420
421 return $clean;
422 }
423
424 /**
425 * Validate a CSS length (e.g. "10px", "1.5rem"); bare numbers become px.
426 *
427 * @param mixed $value Incoming value.
428 * @return string Valid length, or '' when invalid/empty.
429 * @since 1.0.0
430 */
431 private static function sanitize_length( $value ) {
432 if ( is_numeric( $value ) ) {
433 return ( 0 + $value ) . 'px';
434 }
435 $value = is_string( $value ) ? trim( $value ) : '';
436 if ( '' === $value ) {
437 return '';
438 }
439 return preg_match( '/^-?\d*\.?\d+(px|em|rem|%|vw|vh)$/', $value ) ? $value : '';
440 }
441
442 /**
443 * Sanitize a color value via a strict allowlist.
444 *
445 * Accepts hex, rgb()/rgba()/hsl()/hsla() with numeric arguments only, or a
446 * bare named color. Anything else (e.g. "red url(https://…)") is rejected so
447 * a color field cannot smuggle an external resource into the inline style.
448 *
449 * @param mixed $value Incoming color.
450 * @return string Valid color, or '' when invalid/empty.
451 * @since 1.0.0
452 */
453 private static function sanitize_color( $value ) {
454 $value = is_string( $value ) ? trim( $value ) : '';
455 if ( '' === $value ) {
456 return '';
457 }
458 // Hex: #rgb / #rgba / #rrggbb / #rrggbbaa.
459 if ( preg_match( '/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/', $value ) ) {
460 return $value;
461 }
462 // Functional notation with numeric arguments only (no nested functions).
463 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\(\s*[0-9.,%\/\s]+\)$/i', $value ) ) {
464 return $value;
465 }
466 // Named color — letters only, so it cannot contain parens/url()/escapes.
467 if ( preg_match( '/^[a-z]+$/i', $value ) ) {
468 return $value;
469 }
470 // CSS custom-property reference for theme/global palette colors, e.g.
471 // var(--wp--preset--color--primary), with an optional safe fallback
472 // (hex / named / numeric rgb()|hsl() / one nested var). The property name
473 // is restricted to [A-Za-z0-9_-] and the whole value is anchored, so it
474 // cannot contain quotes, semicolons, url() or escapes that would break out
475 // of the inline style attribute.
476 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 ) ) {
477 return $value;
478 }
479 return '';
480 }
481
482 /**
483 * Sanitize a CSS gradient value.
484 *
485 * Requires a (repeating-)?(linear|radial|conic)-gradient(…) shape and rejects
486 * url(), at-rules, and declaration-breaking characters, so the gradient field
487 * cannot reference an external resource or escape the inline style.
488 *
489 * @param mixed $value Incoming gradient.
490 * @return string Valid gradient, or '' when invalid/empty.
491 * @since 1.0.0
492 */
493 private static function sanitize_gradient( $value ) {
494 $value = is_string( $value ) ? trim( $value ) : '';
495 if ( '' === $value ) {
496 return '';
497 }
498 if ( preg_match( '/url\s*\(|@|[;{}<>"\'\\\\]/i', $value ) ) {
499 return '';
500 }
501 if ( ! preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/i', $value ) ) {
502 return '';
503 }
504 return $value;
505 }
506 }
507