PluginProbe
Gutenberg / 23.6.0
Gutenberg v23.6.0
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.6.0, at lib/block-supports/states.php

717 lines 21.6 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` to clear any gradient (whether stored as
117 * the `background` shorthand or as `background-image`) that was applied to
118 * the default / normal state via an inline style attribute. The declaration
119 * is marked important when the state rule is registered with the style engine.
120 */
121 if ( $has_background_color && ! $has_background && ! $has_background_image ) {
122 $declarations['background-image'] = 'unset';
123 }
124
125 return $declarations;
126 }
127
128 /**
129 * Adds fallback dimension styles for aspectRatio and height block-support values.
130 *
131 * @param array $state_style State style object.
132 * @return array State style object with fallback dimension styles applied where needed.
133 */
134 function gutenberg_get_state_style_with_fallback_dimension_styles( $state_style ) {
135 if ( ! is_array( $state_style ) ) {
136 return $state_style;
137 }
138
139 $dimensions = isset( $state_style['dimensions'] ) && is_array( $state_style['dimensions'] )
140 ? $state_style['dimensions']
141 : array();
142
143 if ( empty( $dimensions ) ) {
144 return $state_style;
145 }
146
147 if ( gutenberg_is_explicit_aspect_ratio_value( $dimensions['aspectRatio'] ?? null ) ) {
148 return array_replace_recursive(
149 $state_style,
150 array(
151 'dimensions' => array(
152 'minHeight' => 'unset',
153 'height' => 'unset',
154 ),
155 )
156 );
157 }
158
159 $has_min_height = isset( $dimensions['minHeight'] ) && ( is_string( $dimensions['minHeight'] ) || is_numeric( $dimensions['minHeight'] ) ) && '' !== trim( (string) $dimensions['minHeight'] );
160 $has_height = isset( $dimensions['height'] ) && ( is_string( $dimensions['height'] ) || is_numeric( $dimensions['height'] ) ) && '' !== trim( (string) $dimensions['height'] );
161
162 if ( $has_min_height || $has_height ) {
163 return array_replace_recursive(
164 $state_style,
165 array(
166 'dimensions' => array(
167 'aspectRatio' => 'unset',
168 ),
169 )
170 );
171 }
172
173 return $state_style;
174 }
175
176 /**
177 * Adds a style fragment to a selector-keyed state style group.
178 *
179 * @param array $groups Selector-keyed style groups.
180 * @param string|null $selector Block or feature selector.
181 * @param array $style Style fragment.
182 */
183 function gutenberg_add_state_style_group( &$groups, $selector, $style ) {
184 $key = is_string( $selector ) ? $selector : '';
185
186 if ( ! isset( $groups[ $key ] ) ) {
187 $groups[ $key ] = array(
188 'selector' => $selector,
189 'style' => array(),
190 );
191 }
192
193 $groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style );
194 }
195
196 /**
197 * Splits a state style object into groups based on block feature selectors.
198 *
199 * @param array $state_style State style object.
200 * @param array $block_selectors Block selectors metadata.
201 * @return array[] Selector/style groups.
202 */
203 function gutenberg_get_state_style_groups( $state_style, $block_selectors ) {
204 $groups = array();
205
206 foreach ( $state_style as $feature => $feature_styles ) {
207 $feature_selectors = $block_selectors[ $feature ] ?? null;
208
209 if ( is_string( $feature_selectors ) ) {
210 gutenberg_add_state_style_group(
211 $groups,
212 $feature_selectors,
213 array( $feature => $feature_styles )
214 );
215 continue;
216 }
217
218 if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) {
219 $remaining_styles = $feature_styles;
220
221 foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
222 if (
223 'root' === $subfeature ||
224 ! is_string( $subfeature_selector ) ||
225 ! array_key_exists( $subfeature, $feature_styles )
226 ) {
227 continue;
228 }
229
230 gutenberg_add_state_style_group(
231 $groups,
232 $subfeature_selector,
233 array(
234 $feature => array(
235 $subfeature => $feature_styles[ $subfeature ],
236 ),
237 )
238 );
239 unset( $remaining_styles[ $subfeature ] );
240 }
241
242 if ( array() !== $remaining_styles ) {
243 gutenberg_add_state_style_group(
244 $groups,
245 $feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ),
246 array( $feature => $remaining_styles )
247 );
248 }
249 continue;
250 }
251
252 gutenberg_add_state_style_group(
253 $groups,
254 $block_selectors['root'] ?? null,
255 array( $feature => $feature_styles )
256 );
257 }
258
259 return array_values( $groups );
260 }
261
262 /**
263 * Returns a style object with nested state keys removed.
264 *
265 * @param array $state_style State style object.
266 * @param array $nested_keys Keys to remove from the root style object.
267 * @return array Root-only style object.
268 */
269 function gutenberg_get_root_state_style( $state_style, $nested_keys ) {
270 if ( ! is_array( $state_style ) ) {
271 return $state_style;
272 }
273
274 $root_style = $state_style;
275 foreach ( $nested_keys as $key ) {
276 unset( $root_style[ $key ] );
277 }
278
279 return $root_style;
280 }
281
282 /**
283 * Generates all element selectors for a block root selector.
284 *
285 * @param string $root_selector The block root CSS selector.
286 * @return string[] Element selectors keyed by element name.
287 */
288 function gutenberg_get_block_state_element_selectors( $root_selector ) {
289 if ( ! is_string( $root_selector ) || '' === trim( $root_selector ) ) {
290 return array();
291 }
292
293 $block_selectors = gutenberg_split_selector_list( $root_selector );
294 $element_selectors = array();
295
296 foreach ( WP_Theme_JSON_Gutenberg::ELEMENTS as $element_name => $element_selector ) {
297 $selectors = array();
298
299 foreach ( $block_selectors as $block_selector ) {
300 $block_selector = trim( $block_selector );
301 if ( '' === $block_selector ) {
302 continue;
303 }
304
305 if ( $block_selector === $element_selector ) {
306 $selectors = array( $element_selector );
307 break;
308 }
309
310 $selector_prefix = "$block_selector ";
311 if ( ! str_contains( $element_selector, ',' ) ) {
312 $selectors[] = $selector_prefix . $element_selector;
313 continue;
314 }
315
316 $prepended_selectors = array();
317 foreach ( gutenberg_split_selector_list( $element_selector ) as $selector ) {
318 $prepended_selectors[] = $selector_prefix . $selector;
319 }
320 $selectors[] = implode( ',', $prepended_selectors );
321 }
322
323 if ( ! empty( $selectors ) ) {
324 $element_selectors[ $element_name ] = implode( ',', $selectors );
325 }
326 }
327
328 return $element_selectors;
329 }
330
331 /**
332 * Adds a compiled state style rule to a rule list.
333 *
334 * @param array $css_rules Style rules.
335 * @param string $state Pseudo-state selector.
336 * @param string|null $selector Block, feature, or element selector.
337 * @param array $style Style object.
338 * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
339 */
340 function gutenberg_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null ) {
341 if ( empty( $style ) || ! is_array( $style ) ) {
342 return;
343 }
344
345 $style = gutenberg_get_state_style_with_fallback_dimension_styles( $style );
346
347 $compiled = gutenberg_style_engine_get_styles(
348 gutenberg_normalize_state_style_for_css_output( $style )
349 );
350 $declarations = $compiled['declarations'] ?? array();
351 $text_align = $style['typography']['textAlign'] ?? null;
352 // Base text alignment is class-based, so state styles need a declaration.
353 if ( is_string( $text_align ) && '' !== trim( $text_align ) ) {
354 $declarations['text-align'] = $text_align;
355 }
356
357 if ( empty( $declarations ) ) {
358 return;
359 }
360
361 $css_rules[] = array(
362 'state' => $state,
363 'selector' => $selector,
364 'declarations' => $declarations,
365 );
366 if ( ! empty( $rules_group ) ) {
367 $css_rules[ count( $css_rules ) - 1 ]['rules_group'] = $rules_group;
368 }
369 }
370
371 /**
372 * Builds compiled state style rules, preserving the selector each rule targets.
373 *
374 * @param array $state_styles Map of state to style array.
375 * @param WP_Block_Type $block_type Block type.
376 * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
377 * @return array[] State style rules.
378 */
379 function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) {
380 $css_rules = array();
381 $block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors )
382 ? $block_type->selectors
383 : array();
384
385 foreach ( $state_styles as $state => $state_style ) {
386 if ( empty( $state_style ) || ! is_array( $state_style ) ) {
387 continue;
388 }
389
390 foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) {
391 gutenberg_add_block_state_style_rule(
392 $css_rules,
393 $state,
394 $group['selector'],
395 $group['style'],
396 $rules_group
397 );
398 }
399 }
400
401 return $css_rules;
402 }
403
404 /**
405 * Returns a unique class for a set of state style rules.
406 *
407 * @param string $block_name Block name.
408 * @param array $css_rules State style rules.
409 * @return string Unique class name.
410 */
411 function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) {
412 return 'wp-states-' . substr(
413 md5(
414 wp_json_encode(
415 array(
416 'blockName' => $block_name,
417 'rules' => $css_rules,
418 )
419 )
420 ),
421 0,
422 8
423 );
424 }
425
426 /**
427 * Splits a selector list by top-level commas.
428 *
429 * @param string $selector CSS selector list.
430 * @return string[] Selectors.
431 */
432 function gutenberg_split_selector_list( $selector ) {
433 if ( ! str_contains( $selector, ',' ) ) {
434 return array( $selector );
435 }
436
437 $selectors = array();
438 $current_selector = '';
439 $parentheses_depth = 0;
440 $selector_length = strlen( $selector );
441
442 for ( $i = 0; $i < $selector_length; $i++ ) {
443 $char = $selector[ $i ];
444
445 if ( '(' === $char ) {
446 ++$parentheses_depth;
447 } elseif ( ')' === $char && $parentheses_depth > 0 ) {
448 --$parentheses_depth;
449 } elseif ( ',' === $char && 0 === $parentheses_depth ) {
450 $selectors[] = $current_selector;
451 $current_selector = '';
452 continue;
453 }
454
455 $current_selector .= $char;
456 }
457
458 $selectors[] = $current_selector;
459
460 return $selectors;
461 }
462
463 /**
464 * Builds a scoped selector from a block selector and optional pseudo-state.
465 *
466 * @param string $base_selector Block-instance scoping selector.
467 * @param string|null $block_selector Block or feature selector from metadata.
468 * @param string $state Pseudo-state selector.
469 * @return string Scoped selector.
470 */
471 function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
472 if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
473 return $base_selector . $state;
474 }
475
476 $selectors = gutenberg_split_selector_list( $block_selector );
477 $scoped_selectors = array();
478
479 foreach ( $selectors as $selector ) {
480 $selector = trim( $selector );
481 if ( '' === $selector ) {
482 continue;
483 }
484
485 /*
486 * Replace only the leading block selector part (e.g. class name,
487 * attribute selector, ID, or tag name) with the block instance selector.
488 * Preserve anything after that prefix, including modifier classes on the
489 * same element and combinators without spaces.
490 */
491 if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) {
492 $scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
493 continue;
494 }
495
496 $scoped_selectors[] = $base_selector . $state;
497 }
498
499 return empty( $scoped_selectors )
500 ? $base_selector . $state
501 : implode( ', ', $scoped_selectors );
502 }
503
504 /**
505 * Renders per-instance state styles on the frontend.
506 *
507 * @param string $block_content The block's rendered HTML.
508 * @param array $block The block data including blockName and attrs.
509 * @return string Modified block content with injected state styles.
510 */
511 function gutenberg_render_block_states_support( $block_content, $block ) {
512 if ( empty( $block['blockName'] ) || empty( $block_content ) ) {
513 return $block_content;
514 }
515
516 $block_name = $block['blockName'];
517 $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
518 if ( ! $block_type ) {
519 return $block_content;
520 }
521
522 $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array();
523 $style = gutenberg_resolve_style_state_aliases(
524 $block['attrs']['style'] ?? array(),
525 $block_name
526 );
527 $css_rules = array();
528 $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) );
529 $responsive_media_queries = WP_Theme_JSON_Gutenberg::get_viewport_media_queries( $viewport_settings );
530
531 foreach ( $supported_pseudo_states as $pseudo_state ) {
532 if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) {
533 continue;
534 }
535
536 $css_rules = array_merge(
537 $css_rules,
538 gutenberg_get_block_state_style_rules(
539 array( $pseudo_state => $style[ $pseudo_state ] ),
540 $block_type
541 )
542 );
543 }
544
545 foreach ( $responsive_media_queries as $breakpoint => $media_query ) {
546 if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
547 continue;
548 }
549
550 $root_state_style = gutenberg_get_root_state_style(
551 $style[ $breakpoint ],
552 array_merge( array( 'elements' ), $supported_pseudo_states )
553 );
554
555 if ( ! empty( $root_state_style ) ) {
556 $css_rules = array_merge(
557 $css_rules,
558 gutenberg_get_block_state_style_rules(
559 array( '' => $root_state_style ),
560 $block_type,
561 $media_query
562 )
563 );
564 }
565
566 if (
567 ! empty( $style[ $breakpoint ]['elements'] ) &&
568 is_array( $style[ $breakpoint ]['elements'] )
569 ) {
570 $element_selectors = gutenberg_get_block_state_element_selectors(
571 wp_get_block_css_selector( $block_type )
572 );
573
574 foreach (
575 $style[ $breakpoint ]['elements'] as $element_name => $element_style
576 ) {
577 if (
578 empty( $element_style ) ||
579 ! is_array( $element_style ) ||
580 empty( $element_selectors[ $element_name ] )
581 ) {
582 continue;
583 }
584
585 $element_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ]
586 ?? array();
587 $root_element_style = gutenberg_get_root_state_style(
588 $element_style,
589 $element_pseudo_states
590 );
591
592 gutenberg_add_block_state_style_rule(
593 $css_rules,
594 '',
595 $element_selectors[ $element_name ],
596 $root_element_style,
597 $media_query
598 );
599
600 foreach ( $element_pseudo_states as $pseudo_state ) {
601 if (
602 empty( $element_style[ $pseudo_state ] ) ||
603 ! is_array( $element_style[ $pseudo_state ] )
604 ) {
605 continue;
606 }
607
608 gutenberg_add_block_state_style_rule(
609 $css_rules,
610 $pseudo_state,
611 $element_selectors[ $element_name ],
612 $element_style[ $pseudo_state ],
613 $media_query
614 );
615 }
616 }
617 }
618
619 foreach ( $supported_pseudo_states as $pseudo_state ) {
620 if ( empty( $style[ $breakpoint ][ $pseudo_state ] ) || ! is_array( $style[ $breakpoint ][ $pseudo_state ] ) ) {
621 continue;
622 }
623
624 $css_rules = array_merge(
625 $css_rules,
626 gutenberg_get_block_state_style_rules(
627 array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ),
628 $block_type,
629 $media_query
630 )
631 );
632 }
633 }
634
635 if ( empty( $css_rules ) ) {
636 return $block_content;
637 }
638
639 $unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules );
640
641 /*
642 * Register each state's CSS rules with the block-supports style engine store.
643 * The store deduplicates rules by selector — two block instances with identical
644 * state styles share the same hash class and therefore the same selector,
645 * so only one CSS rule is emitted. The store is flushed to the page by
646 * gutenberg_enqueue_stored_styles() rather than injected inline here.
647 *
648 * State declarations need !important to apply reliably over inline styles and
649 * preset utility classes such as .has-accent-3-background-color.
650 *
651 * Layout-driven state styles (responsive layout, blockGap, child layout) are
652 * handled by gutenberg_render_layout_support_flag() so they share a selector
653 * with the base layout and target the correct (inner) wrapper element.
654 */
655 $style_rules = array();
656 foreach ( $css_rules as $rule ) {
657 $declarations = $rule['declarations'];
658 $important_declaration_values = gutenberg_get_state_declarations_with_background_resets( $declarations );
659 $important_declarations = new WP_Style_Engine_CSS_Declarations_Gutenberg();
660 foreach ( $important_declaration_values as $property => $value ) {
661 $important_declarations->add_declaration(
662 $property,
663 $value,
664 array(
665 'important' => true,
666 )
667 );
668 }
669 $selector = gutenberg_build_state_selector(
670 ".$unique_class",
671 $rule['selector'],
672 $rule['state']
673 );
674 $important_style_rule = array(
675 'selector' => $selector,
676 'declarations' => $important_declarations,
677 );
678 if ( ! empty( $rule['rules_group'] ) ) {
679 $important_style_rule['rules_group'] = $rule['rules_group'];
680 }
681 $style_rules[] = $important_style_rule;
682
683 $fallback_declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
684 foreach ( array_keys( $declarations ) as $property ) {
685 unset( $fallback_declarations[ $property ] );
686 }
687
688 if ( empty( $fallback_declarations ) ) {
689 continue;
690 }
691
692 $fallback_style_rule = array(
693 'selector' => $selector,
694 'declarations' => $fallback_declarations,
695 );
696 if ( ! empty( $rule['rules_group'] ) ) {
697 $fallback_style_rule['rules_group'] = $rule['rules_group'];
698 }
699 $style_rules[] = $fallback_style_rule;
700 }
701
702 gutenberg_style_engine_get_stylesheet_from_css_rules(
703 $style_rules,
704 array(
705 'context' => 'block-supports',
706 'prettify' => false,
707 )
708 );
709
710 $processor = new WP_HTML_Tag_Processor( $block_content );
711 if ( $processor->next_tag() ) {
712 $processor->add_class( $unique_class );
713 }
714 return $processor->get_updated_html();
715 }
716 add_filter( 'render_block', 'gutenberg_render_block_states_support', 10, 2 );
717