PluginProbe
Gutenberg / 23.5.2
Gutenberg v23.5.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / block-supports / states.php

states.php in Gutenberg 23.5.2, at lib/block-supports/states.php

685 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Block state support for frontend CSS generation.
4 *
5 * Generates scoped CSS for per-instance state styles declared in block attributes,
6 * including pseudo-states (e.g., `style[':hover']`) and responsive states
7 * (e.g., `style['@mobile']` and `style['@mobile'][':hover']`).
8 *
9 * @package WordPress
10 */
11
12 /**
13 * Converts internal preset references to CSS custom property references.
14 *
15 * State styles are emitted as CSS rules and cannot rely on preset classnames.
16 * Converting `var:preset|color|contrast` to
17 * `var(--wp--preset--color--contrast)` ensures preset values are emitted as
18 * declarations by the style engine.
19 *
20 * @param mixed $value Style value to normalize.
21 * @return mixed Normalized style value.
22 */
23 function gutenberg_normalize_state_preset_vars( $value ) {
24 if ( is_array( $value ) ) {
25 foreach ( $value as $key => $nested_value ) {
26 $value[ $key ] = gutenberg_normalize_state_preset_vars( $nested_value );
27 }
28 return $value;
29 }
30
31 if ( ! is_string( $value ) || ! str_starts_with( $value, 'var:preset|' ) ) {
32 return $value;
33 }
34
35 $unwrapped_name = str_replace( '|', '--', substr( $value, strlen( 'var:' ) ) );
36 return "var(--wp--$unwrapped_name)";
37 }
38
39 /**
40 * Normalizes a state style object before generating CSS declarations.
41 *
42 * @param array $style State style object.
43 * @return array Normalized state style object.
44 */
45 function gutenberg_normalize_state_style_for_css_output( $style ) {
46 // Layout is processed separately by gutenberg_render_layout_support_flag(), so we remove it before declaration generation.
47 unset( $style['layout'] );
48 $style = gutenberg_normalize_state_preset_vars( $style );
49 return $style;
50 }
51
52 /**
53 * Adds fallback border-style declarations for visible border declarations.
54 *
55 * CSS does not render border color or width unless a border style is also set.
56 * State styles are emitted as stylesheet rules rather than inline styles, so
57 * they cannot rely on the block-library inline-style attribute fallback rules.
58 *
59 * @param array $declarations CSS declarations generated by the style engine.
60 * @return array CSS declarations with fallback border styles applied where needed.
61 */
62 function gutenberg_get_state_declarations_with_fallback_border_styles( $declarations ) {
63 if ( ! is_array( $declarations ) ) {
64 return $declarations;
65 }
66
67 $has_border_style = isset( $declarations['border-style'] ) && '' !== $declarations['border-style'];
68 $has_border_color = isset( $declarations['border-color'] ) && '' !== $declarations['border-color'];
69 $has_border_width = isset( $declarations['border-width'] ) && '' !== $declarations['border-width'];
70
71 if ( ! $has_border_style && ( $has_border_color || $has_border_width ) ) {
72 $declarations['border-style'] = 'solid';
73 }
74
75 $sides = array( 'top', 'right', 'bottom', 'left' );
76 foreach ( $sides as $side ) {
77 $side_style_property = "border-$side-style";
78 $side_color_property = "border-$side-color";
79 $side_width_property = "border-$side-width";
80
81 $has_side_style = isset( $declarations[ $side_style_property ] ) && '' !== $declarations[ $side_style_property ];
82 $has_side_color = isset( $declarations[ $side_color_property ] ) && '' !== $declarations[ $side_color_property ];
83 $has_side_width = isset( $declarations[ $side_width_property ] ) && '' !== $declarations[ $side_width_property ];
84
85 if ( ! $has_border_style && ! $has_side_style && ( $has_side_color || $has_side_width ) ) {
86 $declarations[ $side_style_property ] = 'solid';
87 }
88 }
89
90 return $declarations;
91 }
92
93 /**
94 * Adds background reset declarations to prevent gradient/solid color conflicts.
95 *
96 * When a state sets a solid background-color, any gradient applied to the
97 * default state (via `background` shorthand or `background-image`) must be
98 * explicitly cleared. Without this, the gradient image layer remains visible
99 * on top of the solid hover color even when `!important` is used, because
100 * `background-color` and `background-image` are separate CSS properties.
101 *
102 * @param array $declarations CSS declarations generated by the style engine.
103 * @return array CSS declarations with background resets applied where needed.
104 */
105 function gutenberg_get_state_declarations_with_background_resets( $declarations ) {
106 if ( ! is_array( $declarations ) ) {
107 return $declarations;
108 }
109
110 $has_background_color = isset( $declarations['background-color'] ) && '' !== $declarations['background-color'];
111 $has_background = isset( $declarations['background'] ) && '' !== $declarations['background'];
112 $has_background_image = isset( $declarations['background-image'] ) && '' !== $declarations['background-image'];
113
114 /*
115 * When the state sets a solid background-color but no gradient of its own,
116 * emit `background-image: unset !important` to clear any gradient (whether
117 * stored as the `background` shorthand or as `background-image`) that was
118 * applied to the default / normal state via an inline style attribute.
119 */
120 if ( $has_background_color && ! $has_background && ! $has_background_image ) {
121 $declarations['background-image'] = 'unset !important';
122 }
123
124 return $declarations;
125 }
126
127 /**
128 * Adds fallback dimension styles for aspectRatio and height block-support values.
129 *
130 * @param array $state_style State style object.
131 * @return array State style object with fallback dimension styles applied where needed.
132 */
133 function gutenberg_get_state_style_with_fallback_dimension_styles( $state_style ) {
134 if ( ! is_array( $state_style ) ) {
135 return $state_style;
136 }
137
138 $dimensions = isset( $state_style['dimensions'] ) && is_array( $state_style['dimensions'] )
139 ? $state_style['dimensions']
140 : array();
141
142 if ( empty( $dimensions ) ) {
143 return $state_style;
144 }
145
146 if ( gutenberg_is_explicit_aspect_ratio_value( $dimensions['aspectRatio'] ?? null ) ) {
147 return array_replace_recursive(
148 $state_style,
149 array(
150 'dimensions' => array(
151 'minHeight' => 'unset',
152 'height' => 'unset',
153 ),
154 )
155 );
156 }
157
158 $has_min_height = isset( $dimensions['minHeight'] ) && ( is_string( $dimensions['minHeight'] ) || is_numeric( $dimensions['minHeight'] ) ) && '' !== trim( (string) $dimensions['minHeight'] );
159 $has_height = isset( $dimensions['height'] ) && ( is_string( $dimensions['height'] ) || is_numeric( $dimensions['height'] ) ) && '' !== trim( (string) $dimensions['height'] );
160
161 if ( $has_min_height || $has_height ) {
162 return array_replace_recursive(
163 $state_style,
164 array(
165 'dimensions' => array(
166 'aspectRatio' => 'unset',
167 ),
168 )
169 );
170 }
171
172 return $state_style;
173 }
174
175 /**
176 * Adds a style fragment to a selector-keyed state style group.
177 *
178 * @param array $groups Selector-keyed style groups.
179 * @param string|null $selector Block or feature selector.
180 * @param array $style Style fragment.
181 */
182 function gutenberg_add_state_style_group( &$groups, $selector, $style ) {
183 $key = is_string( $selector ) ? $selector : '';
184
185 if ( ! isset( $groups[ $key ] ) ) {
186 $groups[ $key ] = array(
187 'selector' => $selector,
188 'style' => array(),
189 );
190 }
191
192 $groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style );
193 }
194
195 /**
196 * Splits a state style object into groups based on block feature selectors.
197 *
198 * @param array $state_style State style object.
199 * @param array $block_selectors Block selectors metadata.
200 * @return array[] Selector/style groups.
201 */
202 function gutenberg_get_state_style_groups( $state_style, $block_selectors ) {
203 $groups = array();
204
205 foreach ( $state_style as $feature => $feature_styles ) {
206 $feature_selectors = $block_selectors[ $feature ] ?? null;
207
208 if ( is_string( $feature_selectors ) ) {
209 gutenberg_add_state_style_group(
210 $groups,
211 $feature_selectors,
212 array( $feature => $feature_styles )
213 );
214 continue;
215 }
216
217 if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) {
218 $remaining_styles = $feature_styles;
219
220 foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
221 if (
222 'root' === $subfeature ||
223 ! is_string( $subfeature_selector ) ||
224 ! array_key_exists( $subfeature, $feature_styles )
225 ) {
226 continue;
227 }
228
229 gutenberg_add_state_style_group(
230 $groups,
231 $subfeature_selector,
232 array(
233 $feature => array(
234 $subfeature => $feature_styles[ $subfeature ],
235 ),
236 )
237 );
238 unset( $remaining_styles[ $subfeature ] );
239 }
240
241 if ( array() !== $remaining_styles ) {
242 gutenberg_add_state_style_group(
243 $groups,
244 $feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ),
245 array( $feature => $remaining_styles )
246 );
247 }
248 continue;
249 }
250
251 gutenberg_add_state_style_group(
252 $groups,
253 $block_selectors['root'] ?? null,
254 array( $feature => $feature_styles )
255 );
256 }
257
258 return array_values( $groups );
259 }
260
261 /**
262 * Returns a style object with nested state keys removed.
263 *
264 * @param array $state_style State style object.
265 * @param array $nested_keys Keys to remove from the root style object.
266 * @return array Root-only style object.
267 */
268 function gutenberg_get_root_state_style( $state_style, $nested_keys ) {
269 if ( ! is_array( $state_style ) ) {
270 return $state_style;
271 }
272
273 $root_style = $state_style;
274 foreach ( $nested_keys as $key ) {
275 unset( $root_style[ $key ] );
276 }
277
278 return $root_style;
279 }
280
281 /**
282 * Generates all element selectors for a block root selector.
283 *
284 * @param string $root_selector The block root CSS selector.
285 * @return string[] Element selectors keyed by element name.
286 */
287 function gutenberg_get_block_state_element_selectors( $root_selector ) {
288 if ( ! is_string( $root_selector ) || '' === trim( $root_selector ) ) {
289 return array();
290 }
291
292 $block_selectors = gutenberg_split_selector_list( $root_selector );
293 $element_selectors = array();
294
295 foreach ( WP_Theme_JSON_Gutenberg::ELEMENTS as $element_name => $element_selector ) {
296 $selectors = array();
297
298 foreach ( $block_selectors as $block_selector ) {
299 $block_selector = trim( $block_selector );
300 if ( '' === $block_selector ) {
301 continue;
302 }
303
304 if ( $block_selector === $element_selector ) {
305 $selectors = array( $element_selector );
306 break;
307 }
308
309 $selector_prefix = "$block_selector ";
310 if ( ! str_contains( $element_selector, ',' ) ) {
311 $selectors[] = $selector_prefix . $element_selector;
312 continue;
313 }
314
315 $prepended_selectors = array();
316 foreach ( gutenberg_split_selector_list( $element_selector ) as $selector ) {
317 $prepended_selectors[] = $selector_prefix . $selector;
318 }
319 $selectors[] = implode( ',', $prepended_selectors );
320 }
321
322 if ( ! empty( $selectors ) ) {
323 $element_selectors[ $element_name ] = implode( ',', $selectors );
324 }
325 }
326
327 return $element_selectors;
328 }
329
330 /**
331 * Adds a compiled state style rule to a rule list.
332 *
333 * @param array $css_rules Style rules.
334 * @param string $state Pseudo-state selector.
335 * @param string|null $selector Block, feature, or element selector.
336 * @param array $style Style object.
337 * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
338 */
339 function gutenberg_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null ) {
340 if ( empty( $style ) || ! is_array( $style ) ) {
341 return;
342 }
343
344 $style = gutenberg_get_state_style_with_fallback_dimension_styles( $style );
345
346 $compiled = gutenberg_style_engine_get_styles(
347 gutenberg_normalize_state_style_for_css_output( $style )
348 );
349
350 if ( empty( $compiled['declarations'] ) ) {
351 return;
352 }
353
354 $css_rules[] = array(
355 'state' => $state,
356 'selector' => $selector,
357 'declarations' => $compiled['declarations'],
358 );
359 if ( ! empty( $rules_group ) ) {
360 $css_rules[ count( $css_rules ) - 1 ]['rules_group'] = $rules_group;
361 }
362 }
363
364 /**
365 * Builds compiled state style rules, preserving the selector each rule targets.
366 *
367 * @param array $state_styles Map of state to style array.
368 * @param WP_Block_Type $block_type Block type.
369 * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
370 * @return array[] State style rules.
371 */
372 function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) {
373 $css_rules = array();
374 $block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors )
375 ? $block_type->selectors
376 : array();
377
378 foreach ( $state_styles as $state => $state_style ) {
379 if ( empty( $state_style ) || ! is_array( $state_style ) ) {
380 continue;
381 }
382
383 foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) {
384 gutenberg_add_block_state_style_rule(
385 $css_rules,
386 $state,
387 $group['selector'],
388 $group['style'],
389 $rules_group
390 );
391 }
392 }
393
394 return $css_rules;
395 }
396
397 /**
398 * Returns a unique class for a set of state style rules.
399 *
400 * @param string $block_name Block name.
401 * @param array $css_rules State style rules.
402 * @return string Unique class name.
403 */
404 function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) {
405 return 'wp-states-' . substr(
406 md5(
407 wp_json_encode(
408 array(
409 'blockName' => $block_name,
410 'rules' => $css_rules,
411 )
412 )
413 ),
414 0,
415 8
416 );
417 }
418
419 /**
420 * Splits a selector list by top-level commas.
421 *
422 * @param string $selector CSS selector list.
423 * @return string[] Selectors.
424 */
425 function gutenberg_split_selector_list( $selector ) {
426 if ( ! str_contains( $selector, ',' ) ) {
427 return array( $selector );
428 }
429
430 $selectors = array();
431 $current_selector = '';
432 $parentheses_depth = 0;
433 $selector_length = strlen( $selector );
434
435 for ( $i = 0; $i < $selector_length; $i++ ) {
436 $char = $selector[ $i ];
437
438 if ( '(' === $char ) {
439 ++$parentheses_depth;
440 } elseif ( ')' === $char && $parentheses_depth > 0 ) {
441 --$parentheses_depth;
442 } elseif ( ',' === $char && 0 === $parentheses_depth ) {
443 $selectors[] = $current_selector;
444 $current_selector = '';
445 continue;
446 }
447
448 $current_selector .= $char;
449 }
450
451 $selectors[] = $current_selector;
452
453 return $selectors;
454 }
455
456 /**
457 * Builds a scoped selector from a block selector and optional pseudo-state.
458 *
459 * @param string $base_selector Block-instance scoping selector.
460 * @param string|null $block_selector Block or feature selector from metadata.
461 * @param string $state Pseudo-state selector.
462 * @return string Scoped selector.
463 */
464 function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
465 if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
466 return $base_selector . $state;
467 }
468
469 $selectors = gutenberg_split_selector_list( $block_selector );
470 $scoped_selectors = array();
471
472 foreach ( $selectors as $selector ) {
473 $selector = trim( $selector );
474 if ( '' === $selector ) {
475 continue;
476 }
477
478 /*
479 * Replace only the leading block selector part (e.g. class name,
480 * attribute selector, ID, or tag name) with the block instance selector.
481 * Preserve anything after that prefix, including modifier classes on the
482 * same element and combinators without spaces.
483 */
484 if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) {
485 $scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
486 continue;
487 }
488
489 $scoped_selectors[] = $base_selector . $state;
490 }
491
492 return empty( $scoped_selectors )
493 ? $base_selector . $state
494 : implode( ', ', $scoped_selectors );
495 }
496
497 /**
498 * Renders per-instance state styles on the frontend.
499 *
500 * @param string $block_content The block's rendered HTML.
501 * @param array $block The block data including blockName and attrs.
502 * @return string Modified block content with injected state styles.
503 */
504 function gutenberg_render_block_states_support( $block_content, $block ) {
505 if ( empty( $block['blockName'] ) || empty( $block_content ) ) {
506 return $block_content;
507 }
508
509 $block_name = $block['blockName'];
510 $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
511 if ( ! $block_type ) {
512 return $block_content;
513 }
514
515 $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array();
516 $style = gutenberg_resolve_style_state_aliases(
517 $block['attrs']['style'] ?? array(),
518 $block_name
519 );
520 $css_rules = array();
521
522 foreach ( $supported_pseudo_states as $pseudo_state ) {
523 if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) {
524 continue;
525 }
526
527 $css_rules = array_merge(
528 $css_rules,
529 gutenberg_get_block_state_style_rules(
530 array( $pseudo_state => $style[ $pseudo_state ] ),
531 $block_type
532 )
533 );
534 }
535
536 foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) {
537 if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
538 continue;
539 }
540
541 $root_state_style = gutenberg_get_root_state_style(
542 $style[ $breakpoint ],
543 array_merge( array( 'elements' ), $supported_pseudo_states )
544 );
545
546 if ( ! empty( $root_state_style ) ) {
547 $css_rules = array_merge(
548 $css_rules,
549 gutenberg_get_block_state_style_rules(
550 array( '' => $root_state_style ),
551 $block_type,
552 $media_query
553 )
554 );
555 }
556
557 if (
558 ! empty( $style[ $breakpoint ]['elements'] ) &&
559 is_array( $style[ $breakpoint ]['elements'] )
560 ) {
561 $element_selectors = gutenberg_get_block_state_element_selectors(
562 wp_get_block_css_selector( $block_type )
563 );
564
565 foreach (
566 $style[ $breakpoint ]['elements'] as $element_name => $element_style
567 ) {
568 if (
569 empty( $element_style ) ||
570 ! is_array( $element_style ) ||
571 empty( $element_selectors[ $element_name ] )
572 ) {
573 continue;
574 }
575
576 $element_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ]
577 ?? array();
578 $root_element_style = gutenberg_get_root_state_style(
579 $element_style,
580 $element_pseudo_states
581 );
582
583 gutenberg_add_block_state_style_rule(
584 $css_rules,
585 '',
586 $element_selectors[ $element_name ],
587 $root_element_style,
588 $media_query
589 );
590
591 foreach ( $element_pseudo_states as $pseudo_state ) {
592 if (
593 empty( $element_style[ $pseudo_state ] ) ||
594 ! is_array( $element_style[ $pseudo_state ] )
595 ) {
596 continue;
597 }
598
599 gutenberg_add_block_state_style_rule(
600 $css_rules,
601 $pseudo_state,
602 $element_selectors[ $element_name ],
603 $element_style[ $pseudo_state ],
604 $media_query
605 );
606 }
607 }
608 }
609
610 foreach ( $supported_pseudo_states as $pseudo_state ) {
611 if ( empty( $style[ $breakpoint ][ $pseudo_state ] ) || ! is_array( $style[ $breakpoint ][ $pseudo_state ] ) ) {
612 continue;
613 }
614
615 $css_rules = array_merge(
616 $css_rules,
617 gutenberg_get_block_state_style_rules(
618 array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ),
619 $block_type,
620 $media_query
621 )
622 );
623 }
624 }
625
626 if ( empty( $css_rules ) ) {
627 return $block_content;
628 }
629
630 $unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules );
631
632 /*
633 * Register each state's CSS rules with the block-supports style engine store.
634 * The store deduplicates rules by selector — two block instances with identical
635 * state styles share the same hash class and therefore the same selector,
636 * so only one CSS rule is emitted. The store is flushed to the page by
637 * gutenberg_enqueue_stored_styles() rather than injected inline here.
638 *
639 * State declarations need !important to apply reliably over inline styles and
640 * preset utility classes such as .has-accent-3-background-color.
641 *
642 * Layout-driven state styles (responsive layout, blockGap, child layout) are
643 * handled by gutenberg_render_layout_support_flag() so they share a selector
644 * with the base layout and target the correct (inner) wrapper element.
645 */
646 $style_rules = array();
647 foreach ( $css_rules as $rule ) {
648 $declarations = $rule['declarations'];
649 foreach ( $declarations as $property => $value ) {
650 $declarations[ $property ] = is_string( $value ) && str_contains( $value, '!important' )
651 ? $value
652 : $value . ' !important';
653 }
654 $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
655 $declarations = gutenberg_get_state_declarations_with_background_resets( $declarations );
656 $style_rule = array(
657 'selector' => gutenberg_build_state_selector(
658 ".$unique_class",
659 $rule['selector'],
660 $rule['state']
661 ),
662 'declarations' => $declarations,
663 );
664 if ( ! empty( $rule['rules_group'] ) ) {
665 $style_rule['rules_group'] = $rule['rules_group'];
666 }
667 $style_rules[] = $style_rule;
668 }
669
670 gutenberg_style_engine_get_stylesheet_from_css_rules(
671 $style_rules,
672 array(
673 'context' => 'block-supports',
674 'prettify' => false,
675 )
676 );
677
678 $processor = new WP_HTML_Tag_Processor( $block_content );
679 if ( $processor->next_tag() ) {
680 $processor->add_class( $unique_class );
681 }
682 return $processor->get_updated_html();
683 }
684 add_filter( 'render_block', 'gutenberg_render_block_states_support', 10, 2 );
685