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
← All changes | backend/src/Services/Form/Style/FormStyleService.php +275 -4 1.4 → 1.5 View file →
@@ -48,9 +48,9 @@
48 48 return '';
49 49 }
50 50
51 51 if (isset($styleSettings['stylesEnabled']) && $styleSettings['stylesEnabled'] === false) {
52 - return '';
52 + return self::generateResetCSS($formId, $styleSettings);
53 53 }
54 54
55 55 $extensionCss = apply_filters(
56 56 'ivyforms/form/style/generate_css',
@@ -99,8 +99,245 @@
99 99 return $css->build();
100 100 }
101 101
102 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 + /**
103 340 * Normalize form ID for scoped CSS class names (mirrors frontend selectors helper).
104 341 */
105 342 private static function normalizeFormScopeToken(string $formId): string
106 343 {
@@ -146,11 +383,11 @@
146 383 {
147 384 $formWrapperSelector = '.ivyforms-form-wrapper-' . $formId;
148 385 $innerWrapperSelector = '.ivyforms-form-wrapper.ivyforms-form-wrapper-' . $formId;
149 386 $formSelector = '.ivyforms-form-' . $formId;
150 - $customClass = !empty($styleSettings['customCssClass'])
151 - ? '.' . $styleSettings['customCssClass']
152 - : '';
387 + $parsedCustomClasses = self::parseCssClassList($styleSettings['customCssClass'] ?? '');
388 + $customClassName = $parsedCustomClasses[0] ?? '';
389 + $customClass = $customClassName !== '' ? '.' . $customClassName : '';
153 390
154 391 $baseSelector = $formWrapperSelector;
155 392 if ($customClass) {
156 393 $baseSelector .= $customClass;
@@ -160,6 +397,40 @@
160 397 'base' => $baseSelector,
161 398 'innerWrapper' => $innerWrapperSelector,
162 399 'form' => $formSelector,
163 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);
164 435 }
165 436 }