PluginProbe
The Innovative Form Builder – IvyForms / 1.5
The Innovative Form Builder – IvyForms v1.5
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / Form / Style / FormStyleService.php

FormStyleService.php in The Innovative Form Builder – IvyForms 1.5, at backend/src/Services/Form/Style/FormStyleService.php

437 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See LICENCE.md for license details.
6 */
7
8 namespace IvyForms\Services\Form\Style;
9
10 // phpcs:disable PSR1.Files.SideEffects
11 if (!defined('ABSPATH')) {
12 exit; // Exit if accessed directly
13 }
14
15 /**
16 * Class FormStyleService
17 *
18 * Generates scoped CSS from form style settings
19 *
20 * @package IvyForms\Services\Form
21 */
22 class FormStyleService
23 {
24 /**
25 * Generate scoped CSS for a form
26 *
27 * @param int $formId
28 * @param array<string, mixed>|null $styleSettings
29 * @param string|null $formType
30 * @return string
31 */
32 public static function generateFormCSS(int $formId, ?array $styleSettings, ?string $formType = null): string
33 {
34 $resolvedCss = self::resolveFormCssOutput($formId, $styleSettings, $formType);
35 if ($resolvedCss !== null) {
36 return $resolvedCss;
37 }
38
39 return self::buildClassicFormCss($formId, $styleSettings);
40 }
41
42 /**
43 * @param array<string, mixed>|null $styleSettings
44 */
45 private static function resolveFormCssOutput(int $formId, ?array $styleSettings, ?string $formType): ?string
46 {
47 if (empty($styleSettings)) {
48 return '';
49 }
50
51 if (isset($styleSettings['stylesEnabled']) && $styleSettings['stylesEnabled'] === false) {
52 return self::generateResetCSS($formId, $styleSettings);
53 }
54
55 $extensionCss = apply_filters(
56 'ivyforms/form/style/generate_css',
57 null,
58 $formId,
59 $styleSettings,
60 $formType
61 );
62
63 if (is_string($extensionCss)) {
64 return $extensionCss;
65 }
66
67 if (!empty($formType) && $formType !== 'classic') {
68 return '';
69 }
70
71 return null;
72 }
73
74 /**
75 * @param array<string, mixed> $styleSettings
76 */
77 private static function buildClassicFormCss(int $formId, array $styleSettings): string
78 {
79 $css = new CssBuilder();
80 $context = self::buildContext($formId, $styleSettings);
81
82 // Generate all CSS sections using factory
83 $generators = StyleGeneratorFactory::createGenerators();
84
85 foreach ($generators as $generator) {
86 $generator->generate($css, $context);
87 }
88
89 $extraRules = apply_filters('ivyforms/style/filter/css_rules', [], [
90 'baseSelector' => $context->getBaseSelector(),
91 'styleSettings' => $styleSettings,
92 'formPopperScopeClass' => 'ivyforms-form-popper-' . self::normalizeFormScopeToken((string) $formId),
93 ]);
94
95 if (is_array($extraRules) && $extraRules !== []) {
96 $css->addLines($extraRules);
97 }
98
99 return $css->build();
100 }
101
102 /**
103 * Generate a scoped reset stylesheet for forms with styles disabled (Approach A).
104 *
105 * Strips IvyForms base SCSS + the global Element Plus chrome down to a neutral
106 * baseline so the host WordPress theme's typography/colors cascade in. Scoped to
107 * the form wrapper and the form's popper class so it never affects the rest of
108 * the page. Mirrors the frontend generator in
109 * frontend/src/utils/form-styles/resetStyles.ts — keep the two in sync.
110 *
111 * @param int $formId
112 * @param array<string, mixed> $styleSettings
113 * @return string
114 */
115 private static function generateResetCSS(int $formId, array $styleSettings): string
116 {
117 $selectors = self::buildSelectors($formId, $styleSettings);
118 $base = $selectors['base'];
119 $popper = 'ivyforms-form-popper-' . self::normalizeFormScopeToken((string) $formId);
120
121 $css = new CssBuilder();
122
123 // 0. Neutralize the brand/accent design tokens so no Ivy purple/teal remains
124 // on checkboxes, radios, selects, date pickers, tags, rich-text tabs, etc.
125 // Applied to the form wrapper AND the form's popper scope (poppers render at
126 // <body> level, outside the wrapper).
127 $tokenLines = self::buildNeutralTokenDeclarations();
128 $css->addRule($base, $tokenLines);
129 $css->addRule('.' . $popper, $tokenLines);
130
131 // 1. Typography inheritance — inherited props walk up to the theme.
132 $css->addRule([
133 $base,
134 $base . ' label',
135 $base . ' input',
136 $base . ' textarea',
137 $base . ' .el-input__inner',
138 $base . ' .el-textarea__inner',
139 $base . ' .el-select__placeholder',
140 $base . ' .el-select__selected-item',
141 $base . ' .el-select__selection',
142 $base . ' .vti__input',
143 // Labels — force every label-bearing element to the theme color so
144 // labels are uniform across simple, composite (Name/Address) and
145 // special fields. Some fall through to EP's grey `.el-form-item__label`
146 // or Ivy's dark navy `--map-base-text-0`; both are overridden here.
147 $base . ' .el-form-item__label',
148 $base . ' .ivyforms-form-item__label',
149 $base . ' .ivyforms-form-item__left-label',
150 $base . ' .ivyforms-form-item__right-label',
151 $base . ' .ivyforms-form-item__positioned-right-label',
152 $base . ' .ivyforms-form-item__info-text',
153 $base . ' .ivyforms-name-field__label',
154 $base . ' .ivyforms-name-field__layout__field-wrapper__sublabel',
155 $base . ' .ivyforms-field__address-label',
156 $base . ' .el-checkbox__label',
157 $base . ' .el-radio__label',
158 ], [
159 ' font-family: inherit !important;',
160 ' font-size: inherit !important;',
161 ' font-weight: inherit !important;',
162 ' font-style: inherit !important;',
163 ' line-height: inherit !important;',
164 ' letter-spacing: inherit !important;',
165 ' color: inherit !important;',
166 ]);
167
168 // 2. Field container chrome — strip EP + IvyForms boxing (incl. composite fields).
169 $css->addRule([
170 $base . ' .el-input__wrapper',
171 $base . ' .el-select__wrapper',
172 $base . ' .el-textarea__inner',
173 $base . ' .el-input-number',
174 $base . ' .ivyforms-text-number',
175 $base . ' .ivyforms-text-number__input',
176 $base . ' .vue-tel-input',
177 $base . ' .vti__dropdown',
178 $base . ' .choices__inner',
179 ], [
180 ' box-shadow: none !important;',
181 ' border: 0 !important;',
182 ' background: transparent !important;',
183 ' border-radius: 0 !important;',
184 ' padding: 0 !important;',
185 ' min-height: 0 !important;',
186 ' height: auto !important;',
187 ' overflow: visible !important;',
188 ]);
189
190 // 3. Inner inputs — remove residual borders/backgrounds (no double box).
191 $css->addRule([
192 $base . ' .el-input__inner',
193 $base . ' .el-textarea__inner',
194 $base . ' .el-select__input',
195 $base . ' .el-select__selection',
196 $base . ' .vti__input',
197 ], [
198 ' box-shadow: none !important;',
199 ' border: 0 !important;',
200 ' background: transparent !important;',
201 ' padding: 0 !important;',
202 ' margin: 0 !important;',
203 ' height: auto !important;',
204 ' min-height: 0 !important;',
205 ]);
206
207 // 3b. Release EP fixed-height pins on wrappers/containers.
208 $css->addRule([
209 $base . ' .el-input',
210 $base . ' .el-select',
211 $base . ' .el-date-editor',
212 $base . ' .ivyforms-input',
213 $base . ' .ivyforms-phone-input',
214 $base . ' .ivyforms-field__phone',
215 $base . ' .ivyforms-field__select',
216 $base . ' .ivyforms-field__address',
217 $base . ' .ivyforms-field__date',
218 $base . ' .ivyforms-field__time',
219 ], [
220 ' height: auto !important;',
221 ' min-height: 0 !important;',
222 ]);
223
224 // 4. Buttons — neutral, native-looking button; font inherits the theme.
225 $buttonTargets = [
226 $base . ' .ivyforms-form-submit .ivyforms-button-action',
227 $base . ' .ivyforms-form-submit button.ivyforms-button-action',
228 $base . ' .ivyforms-form-page-navigation .ivyforms-button-action',
229 $base . ' .ivyforms-form-page-navigation button.ivyforms-button-action',
230 ];
231 $css->addRule($buttonTargets, [
232 ' background: revert !important;',
233 ' color: revert !important;',
234 ' border: revert !important;',
235 ' border-radius: 0 !important;',
236 ' box-shadow: none !important;',
237 ' padding: revert !important;',
238 ' width: revert !important;',
239 ' height: revert !important;',
240 ' min-width: 0 !important;',
241 ' min-height: 0 !important;',
242 ' font-family: inherit !important;',
243 ' font-size: inherit !important;',
244 ]);
245 $css->addRule(array_map(static function (string $selector): string {
246 return $selector . ':hover';
247 }, $buttonTargets), [
248 ' opacity: 1 !important;',
249 ' transform: none !important;',
250 ]);
251
252 // 4b. Slider — IvySlider's own custom props come from the neutral "dusk"
253 // token family, so redefine them to the theme color (progress + thumb
254 // become currentColor, empty runway a faint tint). !important guards
255 // against the component's scoped custom-property definitions.
256 $css->addRule($base . ' .ivyforms-slider', [
257 ' --ivy-slider-primary: currentColor !important;',
258 ' --ivy-slider-track-bg: color-mix(in srgb, currentColor 20%, transparent) !important;',
259 ' --ivy-slider-text: currentColor !important;',
260 ]);
261
262 // 5. Poppers render at <body> level; keep EP defaults, just realign the font.
263 $css->addRule([
264 '.' . $popper,
265 '.' . $popper . ' *',
266 ], [
267 ' font-family: inherit !important;',
268 ]);
269
270 return $css->build();
271 }
272
273 /**
274 * Build the custom-property declarations that neutralize the IvyForms brand
275 * accent (mirrors frontend resetStyles.ts buildNeutralTokenDeclarations).
276 * `!important` is required — the :root.wcag-compliant token definitions
277 * out-specify a single wrapper class.
278 *
279 * @return string[]
280 */
281 private static function buildNeutralTokenDeclarations(): array
282 {
283 // Neutral replacements for the IvyForms brand/accent tokens. The goal is
284 // "don't style anything ourselves — match the host theme", so solids
285 // resolve to `currentColor` (the element's inherited theme text color,
286 // which self-adjusts to light/dark) and alpha tokens are a faint tint of
287 // that same color via color-mix(). If color-mix() is unsupported the
288 // declaration is invalid-at-computed-value-time and falls back to
289 // transparent — never back to the Ivy purple.
290 $neutralSolid = 'currentColor';
291 $neutralAlpha = 'color-mix(in srgb, currentColor 12%, transparent)';
292
293 // Translucent tints for the Element Plus primary "light" ramp.
294 $elPrimaryLight = [
295 '3' => 'color-mix(in srgb, currentColor 40%, transparent)',
296 '5' => 'color-mix(in srgb, currentColor 30%, transparent)',
297 '7' => 'color-mix(in srgb, currentColor 20%, transparent)',
298 '8' => 'color-mix(in srgb, currentColor 12%, transparent)',
299 '9' => 'color-mix(in srgb, currentColor 8%, transparent)',
300 ];
301
302 $families = ['brand', 'purple'];
303 $fillLevels = ['4', '3', '2', '1', '0', '-1', '-2', '-3', '-4'];
304 $alphaLevels = ['o90', 'o80', 'o70', 'o60', 'o50', 'o40', 'o30', 'o20', 'o10', 'o05'];
305 $strokeLevels = ['2', '1', '0', '-1', '-2'];
306 $symbolLevels = ['2', '1', '0', '-1'];
307
308 $lines = [];
309 foreach ($families as $family) {
310 foreach ($fillLevels as $level) {
311 $lines[] = ' --map-base-' . $family . '-fill-' . $level . ': ' . $neutralSolid . ' !important;';
312 }
313 foreach ($alphaLevels as $level) {
314 $lines[] = ' --map-base-' . $family . '-' . $level . ': ' . $neutralAlpha . ' !important;';
315 }
316 foreach ($strokeLevels as $level) {
317 $lines[] = ' --map-base-' . $family . '-stroke-' . $level . ': ' . $neutralSolid . ' !important;';
318 }
319 foreach ($symbolLevels as $level) {
320 $lines[] = ' --map-base-' . $family . '-symbol-' . $level . ': ' . $neutralSolid . ' !important;';
321 }
322 }
323
324 // Plain brand token (IvyTimePicker maps --el-color-primary from this).
325 $lines[] = ' --map-base-brand-0: ' . $neutralSolid . ' !important;';
326
327 // Element Plus primary — drives default focus rings and picker highlights.
328 $lines[] = ' --el-color-primary: ' . $neutralSolid . ' !important;';
329 $lines[] = ' --el-color-primary-light-3: ' . $elPrimaryLight['3'] . ' !important;';
330 $lines[] = ' --el-color-primary-light-5: ' . $elPrimaryLight['5'] . ' !important;';
331 $lines[] = ' --el-color-primary-light-7: ' . $elPrimaryLight['7'] . ' !important;';
332 $lines[] = ' --el-color-primary-light-8: ' . $elPrimaryLight['8'] . ' !important;';
333 $lines[] = ' --el-color-primary-light-9: ' . $elPrimaryLight['9'] . ' !important;';
334 $lines[] = ' --el-color-primary-dark-2: ' . $neutralSolid . ' !important;';
335
336 return $lines;
337 }
338
339 /**
340 * Normalize form ID for scoped CSS class names (mirrors frontend selectors helper).
341 */
342 private static function normalizeFormScopeToken(string $formId): string
343 {
344 $normalized = trim($formId);
345 $normalized = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $normalized) ?? '';
346 $normalized = preg_replace('/-+/', '-', $normalized) ?? '';
347 $normalized = trim($normalized, '-');
348
349 return $normalized !== '' ? $normalized : 'default';
350 }
351
352 /**
353 * Build the style context from form ID and settings
354 *
355 * @param int $formId
356 * @param array<string, mixed> $styleSettings
357 * @return StyleContext
358 */
359 private static function buildContext(int $formId, array $styleSettings): StyleContext
360 {
361 $selectors = self::buildSelectors($formId, $styleSettings);
362 $parser = new StyleSettingsParser($styleSettings);
363
364 return new StyleContext(
365 $selectors['base'],
366 $selectors['innerWrapper'],
367 $selectors['form'],
368 $formId,
369 $parser->getActiveColors(),
370 $parser->getActiveStyles(),
371 $styleSettings
372 );
373 }
374
375 /**
376 * Build CSS selectors for the form
377 *
378 * @param int $formId
379 * @param array<string, mixed> $styleSettings
380 * @return array<string, string>
381 */
382 private static function buildSelectors(int $formId, array $styleSettings): array
383 {
384 $formWrapperSelector = '.ivyforms-form-wrapper-' . $formId;
385 $innerWrapperSelector = '.ivyforms-form-wrapper.ivyforms-form-wrapper-' . $formId;
386 $formSelector = '.ivyforms-form-' . $formId;
387 $parsedCustomClasses = self::parseCssClassList($styleSettings['customCssClass'] ?? '');
388 $customClassName = $parsedCustomClasses[0] ?? '';
389 $customClass = $customClassName !== '' ? '.' . $customClassName : '';
390
391 $baseSelector = $formWrapperSelector;
392 if ($customClass) {
393 $baseSelector .= $customClass;
394 }
395
396 return [
397 'base' => $baseSelector,
398 'innerWrapper' => $innerWrapperSelector,
399 'form' => $formSelector,
400 ];
401 }
402
403 /**
404 * Parse a CSS class list with the same semantics as the frontend class parser.
405 *
406 * Splits on commas and whitespace, keeps unique valid class tokens in first-seen
407 * order, and drops tokens that do not match a CSS class name pattern.
408 *
409 * @param mixed $rawValue
410 * @return list<string>
411 */
412 private static function parseCssClassList($rawValue): array
413 {
414 if (!is_string($rawValue) || $rawValue === '') {
415 return [];
416 }
417
418 $uniqueClassNames = [];
419
420 foreach (explode(',', $rawValue) as $entry) {
421 $entry = trim($entry);
422 if ($entry === '') {
423 continue;
424 }
425
426 $classTokens = preg_split('/\s+/', $entry) ?: [];
427 foreach ($classTokens as $token) {
428 if (preg_match('/^[A-Za-z_-][A-Za-z0-9_-]*$/', $token) === 1) {
429 $uniqueClassNames[$token] = true;
430 }
431 }
432 }
433
434 return array_keys($uniqueClassNames);
435 }
436 }
437