| @@ -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 | } |