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

470 lines 14.0 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 a style fragment to a selector-keyed state style group.
95 *
96 * @param array $groups Selector-keyed style groups.
97 * @param string|null $selector Block or feature selector.
98 * @param array $style Style fragment.
99 */
100 function gutenberg_add_state_style_group( &$groups, $selector, $style ) {
101 $key = is_string( $selector ) ? $selector : '';
102
103 if ( ! isset( $groups[ $key ] ) ) {
104 $groups[ $key ] = array(
105 'selector' => $selector,
106 'style' => array(),
107 );
108 }
109
110 $groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style );
111 }
112
113 /**
114 * Splits a state style object into groups based on block feature selectors.
115 *
116 * @param array $state_style State style object.
117 * @param array $block_selectors Block selectors metadata.
118 * @return array[] Selector/style groups.
119 */
120 function gutenberg_get_state_style_groups( $state_style, $block_selectors ) {
121 $groups = array();
122
123 foreach ( $state_style as $feature => $feature_styles ) {
124 $feature_selectors = $block_selectors[ $feature ] ?? null;
125
126 if ( is_string( $feature_selectors ) ) {
127 gutenberg_add_state_style_group(
128 $groups,
129 $feature_selectors,
130 array( $feature => $feature_styles )
131 );
132 continue;
133 }
134
135 if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) {
136 $remaining_styles = $feature_styles;
137
138 foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
139 if (
140 'root' === $subfeature ||
141 ! is_string( $subfeature_selector ) ||
142 ! array_key_exists( $subfeature, $feature_styles )
143 ) {
144 continue;
145 }
146
147 gutenberg_add_state_style_group(
148 $groups,
149 $subfeature_selector,
150 array(
151 $feature => array(
152 $subfeature => $feature_styles[ $subfeature ],
153 ),
154 )
155 );
156 unset( $remaining_styles[ $subfeature ] );
157 }
158
159 if ( array() !== $remaining_styles ) {
160 gutenberg_add_state_style_group(
161 $groups,
162 $feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ),
163 array( $feature => $remaining_styles )
164 );
165 }
166 continue;
167 }
168
169 gutenberg_add_state_style_group(
170 $groups,
171 $block_selectors['root'] ?? null,
172 array( $feature => $feature_styles )
173 );
174 }
175
176 return array_values( $groups );
177 }
178
179 /**
180 * Returns a style object with nested state keys removed.
181 *
182 * @param array $state_style State style object.
183 * @param array $nested_keys Keys to remove from the root style object.
184 * @return array Root-only style object.
185 */
186 function gutenberg_get_root_state_style( $state_style, $nested_keys ) {
187 if ( ! is_array( $state_style ) ) {
188 return $state_style;
189 }
190
191 $root_style = $state_style;
192 foreach ( $nested_keys as $key ) {
193 unset( $root_style[ $key ] );
194 }
195
196 return $root_style;
197 }
198
199 /**
200 * Builds compiled state style rules, preserving the selector each rule targets.
201 *
202 * @param array $state_styles Map of state to style array.
203 * @param WP_Block_Type $block_type Block type.
204 * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
205 * @return array[] State style rules.
206 */
207 function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) {
208 $css_rules = array();
209 $block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors )
210 ? $block_type->selectors
211 : array();
212
213 foreach ( $state_styles as $state => $state_style ) {
214 if ( empty( $state_style ) || ! is_array( $state_style ) ) {
215 continue;
216 }
217
218 foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) {
219 $compiled = gutenberg_style_engine_get_styles(
220 gutenberg_normalize_state_style_for_css_output( $group['style'] )
221 );
222
223 if ( ! empty( $compiled['declarations'] ) ) {
224 $css_rules[] = array(
225 'state' => $state,
226 'selector' => $group['selector'],
227 'declarations' => $compiled['declarations'],
228 );
229 if ( ! empty( $rules_group ) ) {
230 $css_rules[ count( $css_rules ) - 1 ]['rules_group'] = $rules_group;
231 }
232 }
233 }
234 }
235
236 return $css_rules;
237 }
238
239 /**
240 * Returns a unique class for a set of state style rules.
241 *
242 * @param string $block_name Block name.
243 * @param array $css_rules State style rules.
244 * @return string Unique class name.
245 */
246 function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) {
247 return 'wp-states-' . substr(
248 md5(
249 wp_json_encode(
250 array(
251 'blockName' => $block_name,
252 'rules' => $css_rules,
253 )
254 )
255 ),
256 0,
257 8
258 );
259 }
260
261 /**
262 * Splits a selector list by top-level commas.
263 *
264 * @param string $selector CSS selector list.
265 * @return string[] Selectors.
266 */
267 function gutenberg_split_selector_list( $selector ) {
268 if ( ! str_contains( $selector, ',' ) ) {
269 return array( $selector );
270 }
271
272 $selectors = array();
273 $current_selector = '';
274 $parentheses_depth = 0;
275 $selector_length = strlen( $selector );
276
277 for ( $i = 0; $i < $selector_length; $i++ ) {
278 $char = $selector[ $i ];
279
280 if ( '(' === $char ) {
281 ++$parentheses_depth;
282 } elseif ( ')' === $char && $parentheses_depth > 0 ) {
283 --$parentheses_depth;
284 } elseif ( ',' === $char && 0 === $parentheses_depth ) {
285 $selectors[] = $current_selector;
286 $current_selector = '';
287 continue;
288 }
289
290 $current_selector .= $char;
291 }
292
293 $selectors[] = $current_selector;
294
295 return $selectors;
296 }
297
298 /**
299 * Builds a scoped selector from a block selector and optional pseudo-state.
300 *
301 * @param string $base_selector Block-instance scoping selector.
302 * @param string|null $block_selector Block or feature selector from metadata.
303 * @param string $state Pseudo-state selector.
304 * @return string Scoped selector.
305 */
306 function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
307 if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
308 return $base_selector . $state;
309 }
310
311 $selectors = gutenberg_split_selector_list( $block_selector );
312 $scoped_selectors = array();
313
314 foreach ( $selectors as $selector ) {
315 $selector = trim( $selector );
316 if ( '' === $selector ) {
317 continue;
318 }
319
320 /*
321 * Replace only the leading block selector part (e.g. class name,
322 * attribute selector, ID, or tag name) with the block instance selector.
323 * Preserve anything after that prefix, including modifier classes on the
324 * same element and combinators without spaces.
325 */
326 if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) {
327 $scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
328 continue;
329 }
330
331 $scoped_selectors[] = $base_selector . $state;
332 }
333
334 return empty( $scoped_selectors )
335 ? $base_selector . $state
336 : implode( ', ', $scoped_selectors );
337 }
338
339 /**
340 * Renders per-instance state styles on the frontend.
341 *
342 * @param string $block_content The block's rendered HTML.
343 * @param array $block The block data including blockName and attrs.
344 * @return string Modified block content with injected state styles.
345 */
346 function gutenberg_render_block_states_support( $block_content, $block ) {
347 if ( empty( $block['blockName'] ) || empty( $block_content ) ) {
348 return $block_content;
349 }
350
351 $block_name = $block['blockName'];
352 $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
353 if ( ! $block_type ) {
354 return $block_content;
355 }
356
357 $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array();
358 $style = $block['attrs']['style'] ?? array();
359 $css_rules = array();
360
361 foreach ( $supported_pseudo_states as $pseudo_state ) {
362 if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) {
363 continue;
364 }
365
366 $css_rules = array_merge(
367 $css_rules,
368 gutenberg_get_block_state_style_rules(
369 array( $pseudo_state => $style[ $pseudo_state ] ),
370 $block_type
371 )
372 );
373 }
374
375 foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) {
376 if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
377 continue;
378 }
379
380 $root_state_style = gutenberg_get_root_state_style(
381 $style[ $breakpoint ],
382 array_merge( array( 'elements' ), $supported_pseudo_states )
383 );
384
385 if ( ! empty( $root_state_style ) ) {
386 $css_rules = array_merge(
387 $css_rules,
388 gutenberg_get_block_state_style_rules(
389 array( '' => $root_state_style ),
390 $block_type,
391 $media_query
392 )
393 );
394 }
395
396 foreach ( $supported_pseudo_states as $pseudo_state ) {
397 if ( empty( $style[ $breakpoint ][ $pseudo_state ] ) || ! is_array( $style[ $breakpoint ][ $pseudo_state ] ) ) {
398 continue;
399 }
400
401 $css_rules = array_merge(
402 $css_rules,
403 gutenberg_get_block_state_style_rules(
404 array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ),
405 $block_type,
406 $media_query
407 )
408 );
409 }
410 }
411
412 if ( empty( $css_rules ) ) {
413 return $block_content;
414 }
415
416 $unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules );
417
418 /*
419 * Register each state's CSS rules with the block-supports style engine store.
420 * The store deduplicates rules by selector — two block instances with identical
421 * state styles share the same hash class and therefore the same selector,
422 * so only one CSS rule is emitted. The store is flushed to the page by
423 * gutenberg_enqueue_stored_styles() rather than injected inline here.
424 *
425 * State declarations need !important to apply reliably over inline styles and
426 * preset utility classes such as .has-accent-3-background-color.
427 *
428 * Layout-driven state styles (responsive layout, blockGap, child layout) are
429 * handled by gutenberg_render_layout_support_flag() so they share a selector
430 * with the base layout and target the correct (inner) wrapper element.
431 */
432 $style_rules = array();
433 foreach ( $css_rules as $rule ) {
434 $declarations = array();
435 foreach ( $rule['declarations'] as $property => $value ) {
436 $declarations[ $property ] = is_string( $value ) && str_contains( $value, '!important' )
437 ? $value
438 : $value . ' !important';
439 }
440 $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
441 $style_rule = array(
442 'selector' => gutenberg_build_state_selector(
443 ".$unique_class",
444 $rule['selector'],
445 $rule['state']
446 ),
447 'declarations' => $declarations,
448 );
449 if ( ! empty( $rule['rules_group'] ) ) {
450 $style_rule['rules_group'] = $rule['rules_group'];
451 }
452 $style_rules[] = $style_rule;
453 }
454
455 gutenberg_style_engine_get_stylesheet_from_css_rules(
456 $style_rules,
457 array(
458 'context' => 'block-supports',
459 'prettify' => false,
460 )
461 );
462
463 $processor = new WP_HTML_Tag_Processor( $block_content );
464 if ( $processor->next_tag() ) {
465 $processor->add_class( $unique_class );
466 }
467 return $processor->get_updated_html();
468 }
469 add_filter( 'render_block', 'gutenberg_render_block_states_support', 10, 2 );
470