diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index f4eb2dda184386..112baa033cd45b 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -1,9 +1,10 @@ $selector, + 'style' => array(), + ); + } + + $groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style ); +} + +/** + * Splits a state style object into groups based on block feature selectors. + * + * @param array $state_style State style object. + * @param array $block_selectors Block selectors metadata. + * @return array[] Selector/style groups. + */ +function gutenberg_get_state_style_groups( $state_style, $block_selectors ) { + $groups = array(); + + foreach ( $state_style as $feature => $feature_styles ) { + $feature_selectors = $block_selectors[ $feature ] ?? null; + + if ( is_string( $feature_selectors ) ) { + gutenberg_add_state_style_group( + $groups, + $feature_selectors, + array( $feature => $feature_styles ) + ); + continue; + } + + if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) { + $remaining_styles = $feature_styles; + + foreach ( $feature_selectors as $subfeature => $subfeature_selector ) { + if ( + 'root' === $subfeature || + ! is_string( $subfeature_selector ) || + ! array_key_exists( $subfeature, $feature_styles ) + ) { + continue; + } + + gutenberg_add_state_style_group( + $groups, + $subfeature_selector, + array( + $feature => array( + $subfeature => $feature_styles[ $subfeature ], + ), + ) + ); + unset( $remaining_styles[ $subfeature ] ); + } + + if ( array() !== $remaining_styles ) { + gutenberg_add_state_style_group( + $groups, + $feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ), + array( $feature => $remaining_styles ) + ); + } + continue; + } + + gutenberg_add_state_style_group( + $groups, + $block_selectors['root'] ?? null, + array( $feature => $feature_styles ) + ); + } + + return array_values( $groups ); +} + +/** + * Builds compiled state style rules, preserving the selector each rule targets. + * + * @param array $state_styles Map of state to style array. + * @param WP_Block_Type $block_type Block type. + * @param string|null $rules_group Optional style engine rules group, e.g. a media query. + * @return array[] State style rules. + */ +function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) { + $css_rules = array(); + $block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors ) + ? $block_type->selectors + : array(); + + foreach ( $state_styles as $state => $state_style ) { + if ( empty( $state_style ) || ! is_array( $state_style ) ) { + continue; + } + + foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) { + $compiled = gutenberg_style_engine_get_styles( + gutenberg_normalize_state_style_for_css_output( $group['style'] ) + ); + + if ( ! empty( $compiled['declarations'] ) ) { + $css_rule = array( + 'state' => $state, + 'selector' => $group['selector'], + 'declarations' => $compiled['declarations'], + ); + if ( null !== $rules_group ) { + $css_rule['rules_group'] = $rules_group; + } + $css_rules[] = $css_rule; + } + } + } + + return $css_rules; +} + +/** + * Returns a unique class for a set of state style rules. + * + * @param string $block_name Block name. + * @param array $css_rules State style rules. + * @return string Unique class name. + */ +function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) { + return 'wp-states-' . substr( + md5( + wp_json_encode( + array( + 'blockName' => $block_name, + 'rules' => $css_rules, + ) + ) + ), + 0, + 8 + ); +} + +/** + * Builds a scoped selector from a block selector and optional pseudo-state. + * + * @param string $base_selector Block-instance scoping selector. + * @param string|null $block_selector Block or feature selector from metadata. + * @param string $state Pseudo-state selector. + * @return string Scoped selector. + */ +function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) { + if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) { + return $base_selector . $state; + } + + $selectors = explode( ',', $block_selector ); + $scoped_selectors = array(); + + foreach ( $selectors as $selector ) { + $selector = trim( $selector ); + if ( '' === $selector ) { + continue; + } + + if ( preg_match( '/^[^ >+~]+[ >+~](.*)$/', $selector, $matches ) ) { + $scoped_selectors[] = $base_selector . ' ' . trim( $matches[1] ) . $state; + continue; + } + + $scoped_selectors[] = $base_selector . $state; + } + + return empty( $scoped_selectors ) + ? $base_selector . $state + : implode( ', ', $scoped_selectors ); +} + +/** + * Returns a style object with nested state/element keys removed. + * + * Responsive state objects can contain root declarations alongside nested + * `elements` and pseudo-state styles. Only root declarations should be passed + * to the style engine for the viewport root selector. + * + * @param array $state_style State style object. + * @param array $nested_keys Keys to remove from the root style object. + * @return array Root-only style object. + */ +function gutenberg_get_root_state_styles( $state_style, $nested_keys ) { + if ( ! is_array( $state_style ) ) { + return array(); + } + + $root_styles = $state_style; + foreach ( $nested_keys as $nested_key ) { + unset( $root_styles[ $nested_key ] ); + } + return $root_styles; +} + +/** + * Renders per-instance state styles on the frontend. * * @param string $block_content The block's rendered HTML. * @param array $block The block data including blockName and attrs. - * @return string Modified block content with injected pseudo-state styles. + * @return string Modified block content with injected state styles. */ function gutenberg_render_block_states_support( $block_content, $block ) { if ( empty( $block['blockName'] ) || empty( $block_content ) ) { @@ -105,84 +312,96 @@ function gutenberg_render_block_states_support( $block_content, $block ) { return $block_content; } - $supported_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? null; - if ( empty( $supported_states ) || ! is_array( $supported_states ) ) { - return $block_content; + $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); + $style = $block['attrs']['style'] ?? array(); + $css_rules = array(); + + $state_styles = array(); + foreach ( $supported_pseudo_states as $pseudo_state ) { + if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) { + continue; + } + + $state_styles[ $pseudo_state ] = $style[ $pseudo_state ]; } + $css_rules = array_merge( + $css_rules, + gutenberg_get_block_state_style_rules( $state_styles, $block_type ) + ); - $style = $block['attrs']['style'] ?? array(); - $css_rules = array(); + $nested_state_keys = array_merge( array( 'elements' ), $supported_pseudo_states ); - foreach ( $supported_states as $state ) { - if ( empty( $style[ $state ] ) || ! is_array( $style[ $state ] ) ) { + foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { + if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) { continue; } - $compiled = wp_style_engine_get_styles( - gutenberg_normalize_state_style_for_css_output( $style[ $state ] ) + $viewport_styles = $style[ $breakpoint ]; + $root_styles = gutenberg_get_root_state_styles( $viewport_styles, $nested_state_keys ); + $css_rules = array_merge( + $css_rules, + gutenberg_get_block_state_style_rules( + array( '' => $root_styles ), + $block_type, + $media_query + ) ); - if ( ! empty( $compiled['declarations'] ) ) { - $css_rules[] = array( - 'state' => $state, - 'declarations' => $compiled['declarations'], - ); + + $responsive_state_styles = array(); + foreach ( $supported_pseudo_states as $pseudo_state ) { + if ( empty( $viewport_styles[ $pseudo_state ] ) || ! is_array( $viewport_styles[ $pseudo_state ] ) ) { + continue; + } + + $responsive_state_styles[ $pseudo_state ] = $viewport_styles[ $pseudo_state ]; } + $css_rules = array_merge( + $css_rules, + gutenberg_get_block_state_style_rules( + $responsive_state_styles, + $block_type, + $media_query + ) + ); } if ( empty( $css_rules ) ) { return $block_content; } - $unique_class = 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 ); + $unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules ); /* - * Register each pseudo-state's CSS rules with the block-supports style engine store. + * Register each state's CSS rules with the block-supports style engine store. * The store deduplicates rules by selector — two block instances with identical - * pseudo-state styles share the same hash class and therefore the same selector, + * state styles share the same hash class and therefore the same selector, * so only one CSS rule is emitted. The store is flushed to the page by * gutenberg_enqueue_stored_styles() rather than injected inline here. * - * Some block support declarations need !important to apply reliably. Preset-backed - * declarations need to override preset utility classes such as .has-accent-3-background-color, - * while border declarations need to override base styles that can be serialized inline. - * Properties that do not have either conflict do not need !important. + * State declarations need !important to apply reliably over inline styles and + * preset utility classes such as .has-accent-3-background-color. */ - $important_properties = array( - 'color', - 'background-color', - 'border-color', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', - 'border-width', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', - 'border-style', - 'border-top-style', - 'border-right-style', - 'border-bottom-style', - 'border-left-style', - 'background', - 'font-size', - 'font-family', - ); - $style_rules = array(); foreach ( $css_rules as $rule ) { $declarations = array(); foreach ( $rule['declarations'] as $property => $value ) { - $declarations[ $property ] = in_array( $property, $important_properties, true ) - ? $value . ' !important' - : $value; + $declarations[ $property ] = is_string( $value ) && str_contains( $value, '!important' ) + ? $value + : $value . ' !important'; } - $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations ); - $style_rules[] = array( - 'selector' => ".$unique_class{$rule['state']}", + $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations ); + $style_rule = array( + 'selector' => gutenberg_build_state_selector( + ".$unique_class", + $rule['selector'], + $rule['state'] + ), 'declarations' => $declarations, ); + if ( ! empty( $rule['rules_group'] ) ) { + $style_rule['rules_group'] = $rule['rules_group']; + } + $style_rules[] = $style_rule; } gutenberg_style_engine_get_stylesheet_from_css_rules( @@ -193,26 +412,10 @@ function gutenberg_render_block_states_support( $block_content, $block ) { ) ); - // Add the unique class to the interactive element so that pseudo-state - // selectors like `.$unique_class:hover` match directly without needing a descendant. - // If the block declares selectors.root with a descendant (e.g. the button - // block's ".wp-block-button .wp-block-button__link"), we extract the last - // class and walk to that element. Otherwise we fall back to the wrapper. - $root_selector = $block_type->selectors['root'] ?? null; - $target_class = null; - if ( $root_selector && preg_match( '/\.([a-zA-Z0-9_-]+)\s*$/', $root_selector, $matches ) ) { - $target_class = $matches[1]; - } - + // Add the unique class to the wrapper so generated selectors can scope + // root and feature selectors under the block instance. $processor = new WP_HTML_Tag_Processor( $block_content ); - if ( $target_class ) { - while ( $processor->next_tag() ) { - if ( $processor->has_class( $target_class ) ) { - $processor->add_class( $unique_class ); - break; - } - } - } elseif ( $processor->next_tag() ) { + if ( $processor->next_tag() ) { $processor->add_class( $unique_class ); } return $processor->get_updated_html(); diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 6480c36af0767f..d485cb6940f1dd 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -8,7 +8,11 @@ import { hasBlockSupport, store as blocksStore, } from '@wordpress/blocks'; -import { __unstableMotion as motion } from '@wordpress/components'; +import { + ToggleControl, + __experimentalSpacer as Spacer, + __unstableMotion as motion, +} from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { useRef } from '@wordpress/element'; @@ -33,10 +37,12 @@ import AdvancedControls from '../inspector-controls-tabs/advanced-controls-panel import PositionControls from '../inspector-controls-tabs/position-controls-panel'; import useBlockInspectorAnimationSettings from './useBlockInspectorAnimationSettings'; import { useBorderPanelLabel } from '../../hooks/border'; -import { BlockStatesControl } from '../../hooks/states'; +import { BlockStateBadges, BlockStatesControl } from '../../hooks/states'; import ContentTab from '../inspector-controls-tabs/content-tab'; import ViewportVisibilityInfo from '../block-visibility/viewport-visibility-info'; import { unlock } from '../../lock-unlock'; +import { isDefaultBlockStyleState } from '../../hooks/block-style-state'; +import { onViewportStateChangeKey } from '../../store/private-keys'; function StyleInspectorSlots( { blockName, @@ -121,6 +127,8 @@ function BlockInspector() { editedContentOnlySection, blockEditingMode, selectedBlockStyleState, + showStateOnCanvas, + onViewportStateChange, } = useSelect( ( select ) => { const { getSelectedBlockClientId, @@ -133,7 +141,9 @@ function BlockInspector() { isWithinEditedContentOnlySection, getBlockEditingMode, getSelectedBlockStyleState, + isSelectedBlockStyleStateShownOnCanvas, } = unlock( select( blockEditorStore ) ); + const blockEditorSettings = select( blockEditorStore ).getSettings(); const { getBlockStyles } = select( blocksStore ); const _selectedBlockClientId = getSelectedBlockClientId(); const isWithinEditedSection = isWithinEditedContentOnlySection( @@ -168,6 +178,11 @@ function BlockInspector() { selectedBlockStyleState: getSelectedBlockStyleState( _renderedBlockClientId ), + showStateOnCanvas: isSelectedBlockStyleStateShownOnCanvas( + _renderedBlockClientId + ), + onViewportStateChange: + blockEditorSettings?.[ onViewportStateChangeKey ], }; }, [] ); @@ -236,7 +251,9 @@ function BlockInspector() { useBlockInspectorAnimationSettings( blockType ); const hasSelectedBlocks = selectedBlockCount > 1; - const isBlockStyleStateSelected = selectedBlockStyleState !== 'default'; + const isBlockStyleStateSelected = ! isDefaultBlockStyleState( + selectedBlockStyleState + ); if ( hasSelectedBlocks && ! isSectionBlockInSelection ) { return ( @@ -306,6 +323,8 @@ function BlockInspector() { editedContentOnlySection={ editedContentOnlySection } blockEditingMode={ blockEditingMode } selectedBlockStyleState={ selectedBlockStyleState } + showStateOnCanvas={ showStateOnCanvas } + onViewportStateChange={ onViewportStateChange } isBlockStyleStateSelected={ isBlockStyleStateSelected } /> @@ -360,6 +379,8 @@ const BlockInspectorSingleBlock = ( { editedContentOnlySection, blockEditingMode, selectedBlockStyleState, + showStateOnCanvas, + onViewportStateChange, isBlockStyleStateSelected, } ) => { const listViewRef = useRef( null ); @@ -374,9 +395,38 @@ const BlockInspectorSingleBlock = ( { renderedBlockClientId ); const isBlockSynced = blockInformation.isSynced; - const { setSelectedBlockStyleState } = unlock( - useDispatch( blockEditorStore ) - ); + const { + setSelectedBlockStyleState, + setSelectedBlockStyleStateCanvasPreview, + } = unlock( useDispatch( blockEditorStore ) ); + const onBlockStyleStateChange = ( value ) => { + const nextSelectedBlockStyleState = { + ...selectedBlockStyleState, + ...value, + }; + + setSelectedBlockStyleState( + renderedBlockClientId, + nextSelectedBlockStyleState + ); + + if ( value.viewport ) { + onViewportStateChange?.( { + viewport: nextSelectedBlockStyleState.viewport, + showStateOnCanvas, + } ); + } + }; + const onShowStateOnCanvasChange = ( value ) => { + setSelectedBlockStyleStateCanvasPreview( renderedBlockClientId, value ); + + if ( value ) { + onViewportStateChange?.( { + viewport: selectedBlockStyleState.viewport, + showStateOnCanvas: value, + } ); + } + }; return (
@@ -400,16 +450,24 @@ const BlockInspectorSingleBlock = ( { - setSelectedBlockStyleState( - renderedBlockClientId, - value - ) - } + onChange={ onBlockStyleStateChange } /> ) } /> + { blockEditingMode === 'default' && isBlockStyleStateSelected && ( + + + + + ) } diff --git a/packages/block-editor/src/components/global-styles/dimensions-panel.js b/packages/block-editor/src/components/global-styles/dimensions-panel.js index 7c49e3fc060a25..9836bbc63cd07a 100644 --- a/packages/block-editor/src/components/global-styles/dimensions-panel.js +++ b/packages/block-editor/src/components/global-styles/dimensions-panel.js @@ -29,10 +29,17 @@ import ChildLayoutControl from '../child-layout-control'; import AspectRatioTool from '../dimensions-tool/aspect-ratio-tool'; import { cleanEmptyObject } from '../../hooks/utils'; import { setImmutably } from '../../utils/object'; +import { + DEFAULT_BLOCK_STYLE_STATE, + isDefaultBlockStyleState, +} from '../../hooks/block-style-state'; const AXIAL_SIDES = [ 'horizontal', 'vertical' ]; -export function useHasDimensionsPanel( settings, styleState = 'default' ) { +export function useHasDimensionsPanel( + settings, + styleState = DEFAULT_BLOCK_STYLE_STATE +) { return ( Platform.OS === 'web' && ( hasContentSize( settings ) || @@ -89,8 +96,8 @@ function hasAspectRatio( settings ) { return settings?.dimensions?.aspectRatio; } -function hasChildLayout( settings, styleState = 'default' ) { - if ( styleState !== 'default' ) { +function hasChildLayout( settings, styleState = DEFAULT_BLOCK_STYLE_STATE ) { + if ( ! isDefaultBlockStyleState( styleState ) ) { return false; } @@ -235,7 +242,7 @@ export default function DimensionsPanel( { // Special case because the layout controls are not part of the dimensions panel // in global styles but not in block inspector. includeLayoutControls = false, - styleState = 'default', + styleState = DEFAULT_BLOCK_STYLE_STATE, } ) { const { dimensions, spacing } = settings; diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 5e973e242541f6..572544050d62ae 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -25,6 +25,7 @@ import { import { globalStylesDataKey } from '../store/private-keys'; import { getStyleForState, + isDefaultBlockStyleState, setStyleForState, useBlockStyleState, } from './block-style-state'; @@ -193,8 +194,6 @@ export function BackgroundImagePanel( { [ clientId, name ] ); - const isStateSelected = selectedState !== 'default'; - const backgroundGradientSupported = hasBackgroundSupport( name, 'gradient' @@ -221,6 +220,8 @@ export function BackgroundImagePanel( { return null; } + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); + const onChange = isStateSelected ? ( newStyle ) => { setAttributes( { diff --git a/packages/block-editor/src/hooks/block-style-state.js b/packages/block-editor/src/hooks/block-style-state.js index c36d12e6f12f07..85d4dd0994dcaf 100644 --- a/packages/block-editor/src/hooks/block-style-state.js +++ b/packages/block-editor/src/hooks/block-style-state.js @@ -7,8 +7,14 @@ import { createContext, useContext } from '@wordpress/element'; * Internal dependencies */ import { cleanEmptyObject } from './utils'; +import { getValueFromObjectPath, setImmutably } from '../utils/object'; -export const DEFAULT_BLOCK_STYLE_STATE = 'default'; +const DEFAULT_STATE_VALUE = 'default'; + +export const DEFAULT_BLOCK_STYLE_STATE = { + viewport: DEFAULT_STATE_VALUE, + pseudo: DEFAULT_STATE_VALUE, +}; const BlockStyleStateContext = createContext( DEFAULT_BLOCK_STYLE_STATE ); @@ -18,34 +24,84 @@ export function useBlockStyleState() { return useContext( BlockStyleStateContext ); } +/** + * Returns true when a viewport style state is selected. + * + * @param {Object} selectedState Selected block style state. + * @return {boolean} Whether a viewport state is selected. + */ +export function hasViewportBlockStyleState( selectedState ) { + return ( + !! selectedState?.viewport && + selectedState.viewport !== DEFAULT_STATE_VALUE + ); +} + +/** + * Returns true when a pseudo style state is selected. + * + * @param {Object} selectedState Selected block style state. + * @return {boolean} Whether a pseudo state is selected. + */ +export function hasPseudoBlockStyleState( selectedState ) { + return ( + !! selectedState?.pseudo && selectedState.pseudo !== DEFAULT_STATE_VALUE + ); +} + +/** + * Returns true when the default style state is selected. + * + * @param {Object} selectedState Selected block style state. + * @return {boolean} Whether the default style state is selected. + */ +export function isDefaultBlockStyleState( selectedState ) { + return ( + ! hasViewportBlockStyleState( selectedState ) && + ! hasPseudoBlockStyleState( selectedState ) + ); +} + +/** + * Returns the style object path for the selected block style state. + * + * @param {Object} selectedState Selected block style state. + * @return {string[]} Object path for the selected state styles. + */ +function getStyleStatePath( selectedState ) { + if ( isDefaultBlockStyleState( selectedState ) ) { + return []; + } + + return [ selectedState.viewport, selectedState.pseudo ].filter( + ( state ) => state && state !== DEFAULT_STATE_VALUE + ); +} + export function getStyleForState( style, selectedState ) { - if ( ! selectedState || selectedState === DEFAULT_BLOCK_STYLE_STATE ) { + const path = getStyleStatePath( selectedState ); + if ( ! path.length ) { return style; } - return style?.[ selectedState ]; + return getValueFromObjectPath( style, path ); } export function setStyleForState( style, selectedState, newStyle ) { - if ( ! selectedState || selectedState === DEFAULT_BLOCK_STYLE_STATE ) { + const path = getStyleStatePath( selectedState ); + if ( ! path.length ) { return cleanEmptyObject( newStyle ); } - return cleanEmptyObject( { - ...style, - [ selectedState ]: newStyle, - } ); + return cleanEmptyObject( setImmutably( style, path, newStyle ) ); } export function scopeResetAllFilterToState( selectedState, resetAllFilter ) { - if ( - ! resetAllFilter || - ! selectedState || - selectedState === DEFAULT_BLOCK_STYLE_STATE - ) { + if ( ! resetAllFilter || isDefaultBlockStyleState( selectedState ) ) { return resetAllFilter; } return ( attributes ) => { - const existingStateStyle = attributes?.style?.[ selectedState ] || {}; + const existingStateStyle = + getStyleForState( attributes?.style, selectedState ) || {}; const updatedStateAttributes = resetAllFilter( { style: existingStateStyle, } ); @@ -61,10 +117,11 @@ export function scopeResetAllFilterToState( selectedState, resetAllFilter ) { : updatedStateAttributes; return { - style: cleanEmptyObject( { - ...attributes?.style, - [ selectedState ]: updatedStateStyle, - } ), + style: setStyleForState( + attributes?.style, + selectedState, + updatedStateStyle + ), }; }; } diff --git a/packages/block-editor/src/hooks/border.js b/packages/block-editor/src/hooks/border.js index f80d4bfdadb65a..d95e20ea287aa9 100644 --- a/packages/block-editor/src/hooks/border.js +++ b/packages/block-editor/src/hooks/border.js @@ -32,6 +32,7 @@ import { import { store as blockEditorStore } from '../store'; import { getStyleForState, + isDefaultBlockStyleState, setStyleForState, useBlockStyleState, } from './block-style-state'; @@ -161,7 +162,7 @@ export function BorderPanel( { clientId, name, setAttributes, settings } ) { [ clientId, isEnabled ] ); - const isStateSelected = selectedState !== 'default'; + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); const value = useMemo( () => { if ( isStateSelected ) { diff --git a/packages/block-editor/src/hooks/color.js b/packages/block-editor/src/hooks/color.js index 82b0e8d785df15..d2504a9c66392d 100644 --- a/packages/block-editor/src/hooks/color.js +++ b/packages/block-editor/src/hooks/color.js @@ -36,6 +36,7 @@ import BlockColorContrastChecker from './contrast-checker'; import { store as blockEditorStore } from '../store'; import { getStyleForState, + isDefaultBlockStyleState, setStyleForState, useBlockStyleState, } from './block-style-state'; @@ -294,7 +295,7 @@ export function ColorEdit( { [ clientId, isEnabled ] ); - const isStateSelected = selectedState !== 'default'; + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); const value = useMemo( () => { if ( isStateSelected ) { diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js index a989908d1b4588..b968e356b146b3 100644 --- a/packages/block-editor/src/hooks/dimensions.js +++ b/packages/block-editor/src/hooks/dimensions.js @@ -25,6 +25,7 @@ import { unlock } from '../lock-unlock'; import { cleanEmptyObject, shouldSkipSerialization } from './utils'; import { getStyleForState, + isDefaultBlockStyleState, setStyleForState, useBlockStyleState, } from './block-style-state'; @@ -75,8 +76,8 @@ function DimensionsInspectorControl( { children, resetAllFilter } ) { export function DimensionsPanel( { clientId, name, setAttributes, settings } ) { const selectedState = useBlockStyleState(); + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); const isEnabled = useHasDimensionsPanel( settings, selectedState ); - const isStateSelected = selectedState !== 'default'; const style = useSelect( ( select ) => { // Early return to avoid subscription when disabled diff --git a/packages/block-editor/src/hooks/state-utils.js b/packages/block-editor/src/hooks/state-utils.js index 95cda6d50fa95d..34f9a30f6128f4 100644 --- a/packages/block-editor/src/hooks/state-utils.js +++ b/packages/block-editor/src/hooks/state-utils.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { getBlockType } from '@wordpress/blocks'; - /** * Internal dependencies */ @@ -36,50 +31,43 @@ export function getRelativeRootSelector( rootSelector ) { } /** - * Builds the scoped CSS selector for a block state (e.g. :hover, :focus). + * Builds a scoped selector from a block selector and optional suffix. * - * Uses the block's `selectors.root` to determine which element the state - * pseudo-class should apply to. If `selectors.root` describes a descendant - * element (e.g. ".wp-block-button .wp-block-button__link"), the relative - * portion (".wp-block-button__link") is scoped under `baseSelector`. If no - * descendant is present, falls back to appending the state to `baseSelector`. + * If the block selector targets a descendant, the descendant portion is scoped + * under the provided base selector. Otherwise the base selector itself is used. * - * @param {string} baseSelector The block-instance scoping class selector. - * @param {string} name The block name, used to look up selectors. - * @param {string} state The pseudo-class string, e.g. ":hover". - * @return {string} The fully-scoped CSS selector for this state. + * @param {string} baseSelector The block-instance scoping selector. + * @param {string} blockSelector The block or feature selector from block metadata. + * @param {string} suffix Optional selector suffix, e.g. ":hover". + * @return {string} The scoped CSS selector. */ -export function buildStateSelector( baseSelector, name, state ) { - const rootSelector = getBlockType( name )?.selectors?.root; - if ( rootSelector ) { - const relativeSelector = getRelativeRootSelector( rootSelector ); - if ( relativeSelector ) { - return scopeSelector( baseSelector, relativeSelector + state ); - } +export function buildScopedBlockSelector( + baseSelector, + blockSelector, + suffix = '' +) { + if ( typeof blockSelector !== 'string' || ! blockSelector ) { + return `${ baseSelector }${ suffix }`; } - return `${ baseSelector }${ state }`; -} -/** - * Builds the CSS selector used to preview a state on the editor canvas, - * scoped to a specific block instance via its `data-block` attribute. - * - * For blocks whose `selectors.root` targets a descendant element - * (e.g. ".wp-block-button .wp-block-button__link"), the selector targets - * that descendant inside the block wrapper. Otherwise it targets the wrapper - * itself. - * - * @param {string} clientId The block's clientId. - * @param {string} name The block name, used to look up selectors. - * @return {string} CSS selector scoped to this block instance. - */ -export function buildCanvasStateSelector( clientId, name ) { - const rootSelector = getBlockType( name )?.selectors?.root; - if ( rootSelector ) { - const relativeSelector = getRelativeRootSelector( rootSelector ); - if ( relativeSelector ) { - return `[data-block="${ clientId }"] ${ relativeSelector }`; - } + const selectors = blockSelector + .split( ',' ) + .filter( ( selector ) => selector.trim() ); + + if ( ! selectors.length ) { + return `${ baseSelector }${ suffix }`; } - return `[data-block="${ clientId }"]`; + + return selectors + .map( ( selector ) => { + const relativeSelector = getRelativeRootSelector( selector ); + if ( relativeSelector ) { + return scopeSelector( + baseSelector, + `${ relativeSelector }${ suffix }` + ); + } + return `${ baseSelector }${ suffix }`; + } ) + .join( ', ' ); } diff --git a/packages/block-editor/src/hooks/states.js b/packages/block-editor/src/hooks/states.js index 62378e9064b5fc..15681f3851329a 100644 --- a/packages/block-editor/src/hooks/states.js +++ b/packages/block-editor/src/hooks/states.js @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { getBlockType } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; /** @@ -17,6 +18,11 @@ export const PSEUDO_STATE_LABELS = { ':active': __( 'Active' ), }; +export const RESPONSIVE_STATE_LABELS = { + tablet: __( 'Tablet' ), + mobile: __( 'Mobile' ), +}; + // Keep in sync with WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS // and packages/global-styles-engine/src/core/render.tsx. export const VALID_BLOCK_PSEUDO_STATES = { @@ -24,7 +30,7 @@ export const VALID_BLOCK_PSEUDO_STATES = { 'core/navigation-link': [ ':hover', ':focus', ':focus-visible', ':active' ], }; -function getStateOptions( name ) { +function getPseudoStateOptions( name ) { const validStates = VALID_BLOCK_PSEUDO_STATES[ name ] ?? []; return validStates @@ -35,29 +41,49 @@ function getStateOptions( name ) { } ) ); } +const DEFAULT_STATE_VALUE = 'default'; + +function getViewportStateOptions( name ) { + if ( ! getBlockType( name )?.attributes?.style ) { + return []; + } + + return Object.entries( RESPONSIVE_STATE_LABELS ).map( + ( [ value, label ] ) => ( { + value, + label, + } ) + ); +} + /** - * Renders a pseudo-state selector in the block card header. - * Only shown for blocks with configured pseudo-state support. + * Renders a style-state selector in the block card header. + * Viewport states are shown for blocks with a style attribute, while + * pseudo-states are shown for blocks with configured pseudo-state support. * * @param {Object} props Component props. * @param {string} props.name Block name. - * @param {string} props.value Currently selected pseudo-state value. - * @param {Function} props.onChange Callback when pseudo-state selection changes. + * @param {Object} props.value Currently selected style-state value. + * @param {Function} props.onChange Callback when style-state selection changes. * @return {Element|null} State control component, or null if not applicable. */ export function BlockStatesControl( { name, value, onChange } ) { - const stateOptions = getStateOptions( name ); + const viewportStateOptions = getViewportStateOptions( name ); + const pseudoStateOptions = getPseudoStateOptions( name ); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); - if ( ! stateOptions.length ) { + if ( ! viewportStateOptions.length && ! pseudoStateOptions.length ) { return null; } return ( onChange( { viewport } ) } + onChangePseudoState={ ( pseudo ) => onChange( { pseudo } ) } popoverProps={ dropdownMenuProps.popoverProps } showText={ false } /> @@ -65,16 +91,19 @@ export function BlockStatesControl( { name, value, onChange } ) { } export function BlockStateBadges( { name, value } ) { - const stateOptions = getStateOptions( name ); + const viewportStateOptions = getViewportStateOptions( name ); + const pseudoStateOptions = getPseudoStateOptions( name ); - if ( ! stateOptions.length ) { + if ( ! viewportStateOptions.length && ! pseudoStateOptions.length ) { return null; } return ( ); } diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 11931485383776..49abceeabaaa84 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -1,17 +1,13 @@ /** * WordPress dependencies */ -import { useMemo, useState } from '@wordpress/element'; -import { - ToggleControl, - __experimentalSpacer as Spacer, -} from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; import { useSelect } from '@wordpress/data'; import { mergeGlobalStyles } from '@wordpress/global-styles-engine'; import { getBlockSupport, + getBlockType, hasBlockSupport, __EXPERIMENTAL_ELEMENTS as ELEMENTS, } from '@wordpress/blocks'; @@ -40,10 +36,15 @@ import { useStyleOverride, useBlockSettings, } from './utils'; -import { BlockStyleStateProvider } from './block-style-state'; -import { BlockStateBadges, VALID_BLOCK_PSEUDO_STATES } from './states'; -import { buildStateSelector, buildCanvasStateSelector } from './state-utils'; -import { BlockInspectorPreTabsFill } from '../components/block-inspector/inspector-pre-tabs-slot-fill'; +import { + BlockStyleStateProvider, + DEFAULT_BLOCK_STYLE_STATE, + getStyleForState, + hasViewportBlockStyleState, + hasPseudoBlockStyleState, +} from './block-style-state'; +import { VALID_BLOCK_PSEUDO_STATES } from './states'; +import { buildScopedBlockSelector } from './state-utils'; import { scopeSelector } from '../components/global-styles/utils'; import { useBlockEditingMode } from '../components/block-editing-mode'; import { store as blockEditorStore } from '../store'; @@ -52,6 +53,13 @@ import { unlock } from '../lock-unlock'; const BORDER_SIDES = [ 'Top', 'Right', 'Bottom', 'Left' ]; +// Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS and +// packages/global-styles-engine/src/core/render.tsx. +const RESPONSIVE_BREAKPOINTS = { + mobile: '@media (width <= 480px)', + tablet: '@media (480px < width <= 782px)', +}; + const styleSupportKeys = [ ...TYPOGRAPHY_SUPPORT_KEYS, BORDER_SUPPORT_KEY, @@ -148,6 +156,261 @@ export function getStateStylesCSS( stateStyles, selector ) { return [ importantCSS, fallbackCSS ].filter( Boolean ).join( '\n' ); } +function isPlainObject( value ) { + return !! value && typeof value === 'object' && ! Array.isArray( value ); +} + +function mergeStyleObjects( target = {}, source = {} ) { + const merged = { ...target }; + + Object.entries( source ).forEach( ( [ key, value ] ) => { + merged[ key ] = + isPlainObject( value ) && isPlainObject( merged[ key ] ) + ? mergeStyleObjects( merged[ key ], value ) + : value; + } ); + + return merged; +} + +function addStyleGroup( groups, selector, style ) { + const key = selector || ''; + const existing = groups.get( key ) || { selector, style: {} }; + + groups.set( key, { + selector, + style: mergeStyleObjects( existing.style, style ), + } ); +} + +function getStateStyleGroups( stateStyles, name ) { + const blockSelectors = getBlockType( name )?.selectors || {}; + const groups = new Map(); + + Object.entries( stateStyles || {} ).forEach( + ( [ feature, featureStyles ] ) => { + const featureSelectors = blockSelectors[ feature ]; + + if ( typeof featureSelectors === 'string' ) { + addStyleGroup( groups, featureSelectors, { + [ feature ]: featureStyles, + } ); + return; + } + + if ( + isPlainObject( featureSelectors ) && + isPlainObject( featureStyles ) + ) { + const remainingStyles = { ...featureStyles }; + + Object.entries( featureSelectors ).forEach( + ( [ subfeature, subfeatureSelector ] ) => { + if ( + subfeature === 'root' || + typeof subfeatureSelector !== 'string' || + ! Object.hasOwn( featureStyles, subfeature ) + ) { + return; + } + + addStyleGroup( groups, subfeatureSelector, { + [ feature ]: { + [ subfeature ]: featureStyles[ subfeature ], + }, + } ); + delete remainingStyles[ subfeature ]; + } + ); + + if ( Object.keys( remainingStyles ).length ) { + addStyleGroup( + groups, + featureSelectors.root || blockSelectors.root, + { + [ feature ]: remainingStyles, + } + ); + } + return; + } + + addStyleGroup( groups, blockSelectors.root, { + [ feature ]: featureStyles, + } ); + } + ); + + return Array.from( groups.values() ); +} + +/** + * Generates CSS for block instance state styles, honoring feature selectors. + * + * @param {Object} stateStyles State style object. + * @param {Object} options Generation options. + * @param {string} options.name Block name. + * @param {string} options.baseSelector Block-instance scoping selector. + * @param {string=} options.state Optional pseudo-state, e.g. ":hover". + * @return {string|undefined} Generated stylesheet. + */ +export function getBlockStateStylesCSS( stateStyles, options ) { + const { name, baseSelector, state = '' } = options; + const rules = getStateStyleGroups( stateStyles, name ) + .map( ( { selector: blockSelector, style } ) => + getStateStylesCSS( + style, + buildScopedBlockSelector( baseSelector, blockSelector, state ) + ) + ) + .filter( Boolean ); + + return rules.length ? rules.join( '\n' ) : undefined; +} + +/** + * Returns a style object with nested state/element keys removed. + * + * Viewport state objects can contain root declarations alongside nested + * `elements` and pseudo-state styles. Only root declarations should be passed + * to the style engine for the viewport root selector. + * + * @param {Object} stateStyles Style object for a selected state. + * @param {string[]} nestedKeys Keys to remove from the root style object. + * @return {Object|undefined} Root-only style object. + */ +function getRootStateStyles( stateStyles, nestedKeys ) { + if ( ! stateStyles ) { + return stateStyles; + } + + const rootStyles = { ...stateStyles }; + nestedKeys.forEach( ( key ) => { + delete rootStyles[ key ]; + } ); + return rootStyles; +} + +/** + * Generates CSS rules for supported pseudo-state styles. + * + * @param {Object} style Block style object containing pseudo-state styles. + * @param {string} name Block name. + * @param {string} baseSelector Base selector used to scope generated CSS. + * @return {string[]} Generated CSS rule strings. + */ +function getPseudoStateCSSRules( style, name, baseSelector ) { + const validPseudoStates = VALID_BLOCK_PSEUDO_STATES[ name ]; + if ( ! validPseudoStates ) { + return []; + } + + const cssRules = []; + validPseudoStates.forEach( ( pseudoState ) => { + const stateStyles = style?.[ pseudoState ]; + if ( stateStyles ) { + const css = getBlockStateStylesCSS( stateStyles, { + name, + baseSelector, + state: pseudoState, + } ); + if ( css ) { + cssRules.push( css ); + } + } + } ); + return cssRules; +} + +/** + * Generates CSS rules for responsive block instance style states. + * + * Each responsive state can contain root styles, element styles, and nested + * pseudo-state styles. Generated rules are wrapped in the matching breakpoint + * media query. + * + * @param {Object} style Block style object containing responsive states. + * @param {string} name Block name. + * @param {string} baseSelector Base selector used to scope generated CSS. + * @return {string[]} Generated CSS rule strings. + */ +export function getResponsiveStateCSSRules( style, name, baseSelector ) { + const cssRules = []; + const validPseudoStates = VALID_BLOCK_PSEUDO_STATES[ name ] ?? []; + const nestedStateKeys = [ 'elements', ...validPseudoStates ]; + + Object.entries( RESPONSIVE_BREAKPOINTS ).forEach( + ( [ viewport, mediaQuery ] ) => { + const viewportStyles = style?.[ viewport ]; + if ( ! viewportStyles ) { + return; + } + + const viewportCSSRules = []; + const rootCSS = getBlockStateStylesCSS( + getRootStateStyles( viewportStyles, nestedStateKeys ), + { name, baseSelector } + ); + if ( rootCSS ) { + viewportCSSRules.push( rootCSS ); + } + + const elementCSS = getElementCSSRules( + viewportStyles.elements, + name, + baseSelector + ); + if ( elementCSS ) { + viewportCSSRules.push( elementCSS ); + } + + viewportCSSRules.push( + ...getPseudoStateCSSRules( viewportStyles, name, baseSelector ) + ); + + if ( viewportCSSRules.length ) { + cssRules.push( + `${ mediaQuery }{${ viewportCSSRules.join( '' ) }}` + ); + } + } + ); + + return cssRules; +} + +/** + * Returns the style value used to force-preview a selected state on canvas. + * + * Responsive pseudo states inherit from their default-viewport pseudo state. + * For example, selecting `mobile + :hover` should preview styles from + * `:hover`, with `mobile.:hover` values layered on top when present. + * + * @param {Object} style Block style object. + * @param {Object} selectedState Selected block style state. + * @return {Object|undefined} Style value for the canvas preview. + */ +export function getCanvasStateStyleValue( style, selectedState ) { + const stateValue = getStyleForState( style, selectedState ); + if ( ! hasViewportBlockStyleState( selectedState ) ) { + return stateValue; + } + + const defaultViewportState = { + ...selectedState, + viewport: DEFAULT_BLOCK_STYLE_STATE.viewport, + }; + const defaultViewportStateValue = getStyleForState( + style, + defaultViewportState + ); + + if ( defaultViewportStateValue && stateValue ) { + return mergeGlobalStyles( defaultViewportStateValue, stateValue ); + } + return stateValue || defaultViewportStateValue; +} + /** * Filters registered block settings, extending attributes to include `style` attribute. * @@ -414,33 +677,43 @@ function BlockStyleControls( { } ) { const settings = useBlockSettings( name, __unstableParentLayout ); const blockEditingMode = useBlockEditingMode(); - const [ showStateOnCanvas, setShowStateOnCanvas ] = useState( true ); - const { globalBlockStyles, selectedState } = useSelect( + const { globalBlockStyles, selectedState, showStateOnCanvas } = useSelect( ( select ) => { const blockEditorSelect = select( blockEditorStore ); + const { + getSelectedBlockStyleState, + isSelectedBlockStyleStateShownOnCanvas, + } = unlock( blockEditorSelect ); + const editorSettings = blockEditorSelect.getSettings(); return { globalBlockStyles: - blockEditorSelect.getSettings()[ globalStylesDataKey ] - ?.blocks?.[ name ], - selectedState: - unlock( blockEditorSelect ).getSelectedBlockStyleState( - clientId - ), + editorSettings?.[ globalStylesDataKey ]?.blocks?.[ name ], + selectedState: getSelectedBlockStyleState( clientId ), + showStateOnCanvas: + isSelectedBlockStyleStateShownOnCanvas( clientId ), }; }, [ clientId, name ] ); - const globalStateValue = globalBlockStyles?.[ selectedState ]; - const instanceStateValue = style?.[ selectedState ]; + const isPseudoSelectorState = hasPseudoBlockStyleState( selectedState ); // Inject state styles onto the editor canvas so the selected state is // visible while editing. Scoped to this block instance via data-block so // other blocks of the same type are not affected. Must be called before // any early returns because it is a hook. const canvasStateCSS = useMemo( () => { - if ( ! showStateOnCanvas || selectedState === 'default' ) { + if ( ! showStateOnCanvas || ! isPseudoSelectorState ) { return undefined; } + + const globalStateValue = getCanvasStateStyleValue( + globalBlockStyles, + selectedState + ); + const instanceStateValue = getCanvasStateStyleValue( + style, + selectedState + ); let stateValue; if ( globalStateValue && instanceStateValue ) { @@ -456,13 +729,16 @@ function BlockStyleControls( { return undefined; } - const selector = buildCanvasStateSelector( clientId, name ); - return getStateStylesCSS( stateValue, selector ); + return getBlockStateStylesCSS( stateValue, { + name, + baseSelector: `[data-block="${ clientId }"]`, + } ); }, [ showStateOnCanvas, + isPseudoSelectorState, + globalBlockStyles, + style, selectedState, - globalStateValue, - instanceStateValue, clientId, name, ] ); @@ -490,30 +766,13 @@ function BlockStyleControls( { }; return ( - <> - { selectedState !== 'default' && ( - - - - - - - ) } - - - - - - - - + + + + + + + ); } @@ -639,24 +898,13 @@ function useBlockProps( { name, style } ) { cssRules.push( elementCSS ); } - // Generate per-instance pseudo-state CSS (e.g., :hover, :focus). - const validStates = VALID_BLOCK_PSEUDO_STATES[ name ]; - if ( validStates ) { - validStates.forEach( ( state ) => { - const stateStyles = style?.[ state ]; - if ( stateStyles ) { - const selector = buildStateSelector( - baseElementSelector, - name, - state - ); - const css = getStateStylesCSS( stateStyles, selector ); - if ( css ) { - cssRules.push( css ); - } - } - } ); - } + cssRules.push( + ...getPseudoStateCSSRules( style, name, baseElementSelector ) + ); + + cssRules.push( + ...getResponsiveStateCSSRules( style, name, baseElementSelector ) + ); return cssRules.length > 0 ? cssRules.join( '' ) : undefined; }, [ baseElementSelector, blockElementStyles, name, style ] ); diff --git a/packages/block-editor/src/hooks/test/block-style-state.js b/packages/block-editor/src/hooks/test/block-style-state.js index 6f059a951879b9..808102bf593989 100644 --- a/packages/block-editor/src/hooks/test/block-style-state.js +++ b/packages/block-editor/src/hooks/test/block-style-state.js @@ -11,16 +11,59 @@ describe( 'getStyleForState', () => { it( 'returns the root style for the default state', () => { const style = { color: { text: '#000000' } }; - expect( getStyleForState( style, 'default' ) ).toBe( style ); + expect( + getStyleForState( style, { + viewport: 'default', + pseudo: 'default', + } ) + ).toBe( style ); } ); - it( 'returns the selected state style', () => { + it( 'returns the selected pseudo state style', () => { const style = { color: { text: '#000000' }, ':hover': { color: { text: '#ff0000' } }, }; - expect( getStyleForState( style, ':hover' ) ).toEqual( { + expect( + getStyleForState( style, { + viewport: 'default', + pseudo: ':hover', + } ) + ).toEqual( { + color: { text: '#ff0000' }, + } ); + } ); + + it( 'returns the selected viewport state style', () => { + const style = { + color: { text: '#000000' }, + mobile: { color: { text: '#ff0000' } }, + }; + + expect( + getStyleForState( style, { + viewport: 'mobile', + pseudo: 'default', + } ) + ).toEqual( { + color: { text: '#ff0000' }, + } ); + } ); + + it( 'returns the selected viewport pseudo state style', () => { + const style = { + mobile: { + ':hover': { color: { text: '#ff0000' } }, + }, + }; + + expect( + getStyleForState( style, { + viewport: 'mobile', + pseudo: ':hover', + } ) + ).toEqual( { color: { text: '#ff0000' }, } ); } ); @@ -29,22 +72,26 @@ describe( 'getStyleForState', () => { describe( 'setStyleForState', () => { it( 'replaces the root style for the default state', () => { expect( - setStyleForState( { color: { text: '#000000' } }, 'default', { - typography: { fontSize: '32px' }, - } ) + setStyleForState( + { color: { text: '#000000' } }, + { viewport: 'default', pseudo: 'default' }, + { + typography: { fontSize: '32px' }, + } + ) ).toEqual( { typography: { fontSize: '32px' }, } ); } ); - it( 'updates only the selected state style', () => { + it( 'updates only the selected pseudo state style', () => { expect( setStyleForState( { color: { text: '#000000' }, ':hover': { color: { text: '#ff0000' } }, }, - ':hover', + { viewport: 'default', pseudo: ':hover' }, { typography: { fontSize: '32px' } } ) ).toEqual( { @@ -53,6 +100,44 @@ describe( 'setStyleForState', () => { } ); } ); + it( 'updates only the selected viewport state style', () => { + expect( + setStyleForState( + { + color: { text: '#000000' }, + mobile: { color: { text: '#ff0000' } }, + }, + { viewport: 'mobile', pseudo: 'default' }, + { typography: { fontSize: '32px' } } + ) + ).toEqual( { + color: { text: '#000000' }, + mobile: { typography: { fontSize: '32px' } }, + } ); + } ); + + it( 'updates only the selected viewport pseudo state style', () => { + expect( + setStyleForState( + { + color: { text: '#000000' }, + mobile: { + color: { text: '#ff0000' }, + ':hover': { color: { text: '#00ff00' } }, + }, + }, + { viewport: 'mobile', pseudo: ':hover' }, + { typography: { fontSize: '32px' } } + ) + ).toEqual( { + color: { text: '#000000' }, + mobile: { + color: { text: '#ff0000' }, + ':hover': { typography: { fontSize: '32px' } }, + }, + } ); + } ); + it( 'removes the selected state key when the new state style is empty', () => { expect( setStyleForState( @@ -60,7 +145,7 @@ describe( 'setStyleForState', () => { color: { text: '#000000' }, ':hover': { color: { text: '#ff0000' } }, }, - ':hover', + { viewport: 'default', pseudo: ':hover' }, { color: { text: undefined } } ) ).toEqual( { @@ -91,7 +176,7 @@ describe( 'scopeResetAllFilterToState', () => { }; const result = scopeResetAllFilterToState( - ':hover', + { viewport: 'default', pseudo: ':hover' }, innerReset )( attributes ); @@ -119,7 +204,7 @@ describe( 'scopeResetAllFilterToState', () => { }; const result = scopeResetAllFilterToState( - ':hover', + { viewport: 'default', pseudo: ':hover' }, innerReset )( attributes ); @@ -136,16 +221,50 @@ describe( 'scopeResetAllFilterToState', () => { style: { color: { text: '#000000' } }, }; - scopeResetAllFilterToState( ':hover', innerReset )( attributes ); + scopeResetAllFilterToState( + { viewport: 'default', pseudo: ':hover' }, + innerReset + )( attributes ); expect( innerReset ).toHaveBeenCalledWith( { style: {} } ); } ); + it( 'passes only the selected viewport pseudo state style to the reset filter', () => { + const innerReset = jest.fn( () => ( { style: undefined } ) ); + const attributes = { + style: { + color: { text: '#000000' }, + mobile: { + color: { text: '#ff0000' }, + ':hover': { color: { text: '#00ff00' } }, + }, + }, + }; + + const result = scopeResetAllFilterToState( + { viewport: 'mobile', pseudo: ':hover' }, + innerReset + )( attributes ); + + expect( innerReset ).toHaveBeenCalledWith( { + style: attributes.style.mobile[ ':hover' ], + } ); + expect( result ).toEqual( { + style: { + color: { text: '#000000' }, + mobile: { color: { text: '#ff0000' } }, + }, + } ); + } ); + it( 'returns the original reset filter for the default state', () => { const innerReset = () => ( { style: undefined } ); - expect( scopeResetAllFilterToState( 'default', innerReset ) ).toBe( - innerReset - ); + expect( + scopeResetAllFilterToState( + { viewport: 'default', pseudo: 'default' }, + innerReset + ) + ).toBe( innerReset ); } ); } ); diff --git a/packages/block-editor/src/hooks/test/state-utils.js b/packages/block-editor/src/hooks/test/state-utils.js index 0d6f6437615047..9547e55fba7ba4 100644 --- a/packages/block-editor/src/hooks/test/state-utils.js +++ b/packages/block-editor/src/hooks/test/state-utils.js @@ -1,12 +1,10 @@ -/** - * WordPress dependencies - */ -import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; - /** * Internal dependencies */ -import { getRelativeRootSelector, buildStateSelector } from '../state-utils'; +import { + getRelativeRootSelector, + buildScopedBlockSelector, +} from '../state-utils'; describe( 'getRelativeRootSelector', () => { it( 'returns the descendant part of a space-combinator selector', () => { @@ -32,59 +30,44 @@ describe( 'getRelativeRootSelector', () => { } ); } ); -describe( 'buildStateSelector', () => { +describe( 'buildScopedBlockSelector', () => { const BASE = '.wp-elements-abc123'; - beforeEach( () => { - registerBlockType( 'test/button', { - apiVersion: 3, - title: 'Button', - category: 'text', - attributes: {}, - edit: () => null, - save: () => null, - selectors: { - root: '.wp-block-button .wp-block-button__link', - }, - } ); - registerBlockType( 'test/plain', { - apiVersion: 3, - title: 'Plain', - category: 'text', - attributes: {}, - edit: () => null, - save: () => null, - } ); - } ); - - afterEach( () => { - unregisterBlockType( 'test/button' ); - unregisterBlockType( 'test/plain' ); - } ); - - it( 'scopes state to the descendant element from selectors.root', () => { - expect( buildStateSelector( BASE, 'test/button', ':hover' ) ).toBe( - `${ BASE } .wp-block-button__link:hover` - ); + it( 'scopes a suffix to the descendant element from a block selector', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':hover' + ) + ).toBe( `${ BASE } .wp-block-button__link:hover` ); } ); it( 'works for :focus and :active states', () => { - expect( buildStateSelector( BASE, 'test/button', ':focus' ) ).toBe( - `${ BASE } .wp-block-button__link:focus` - ); - expect( buildStateSelector( BASE, 'test/button', ':active' ) ).toBe( - `${ BASE } .wp-block-button__link:active` - ); + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':focus' + ) + ).toBe( `${ BASE } .wp-block-button__link:focus` ); + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':active' + ) + ).toBe( `${ BASE } .wp-block-button__link:active` ); } ); - it( 'falls back to appending state to the base selector when block has no selectors.root', () => { - expect( buildStateSelector( BASE, 'test/plain', ':hover' ) ).toBe( - `${ BASE }:hover` - ); + it( 'falls back to appending the suffix to the base selector when there is no descendant', () => { + expect( + buildScopedBlockSelector( BASE, '.wp-block-button', ':hover' ) + ).toBe( `${ BASE }:hover` ); } ); - it( 'falls back to appending state to the base selector for an unknown block name', () => { - expect( buildStateSelector( BASE, 'test/unknown', ':hover' ) ).toBe( + it( 'falls back to appending the suffix to the base selector when the block selector is missing', () => { + expect( buildScopedBlockSelector( BASE, undefined, ':hover' ) ).toBe( `${ BASE }:hover` ); } ); diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index 4872ea4048e910..eca319b3a41dbc 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -1,8 +1,16 @@ +/** + * WordPress dependencies + */ +import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; + /** * Internal dependencies */ import _style, { + getBlockStateStylesCSS, + getCanvasStateStyleValue, getInlineStyles, + getResponsiveStateCSSRules, getStateStylesCSS, omitStyle, } from '../style'; @@ -202,6 +210,207 @@ describe( 'getStateStylesCSS', () => { } ); } ); +describe( 'getBlockStateStylesCSS', () => { + beforeEach( () => { + registerBlockType( 'test/state-button', { + apiVersion: 3, + title: 'State Button', + category: 'text', + attributes: {}, + edit: () => null, + save: () => null, + selectors: { + root: '.wp-block-button .wp-block-button__link', + dimensions: { + root: '.wp-block-button', + width: '.wp-block-button', + }, + }, + } ); + } ); + + afterEach( () => { + unregisterBlockType( 'test/state-button' ); + } ); + + it( 'routes state styles through feature selectors', () => { + expect( + getBlockStateStylesCSS( + { + color: { background: '#ff00d0' }, + dimensions: { width: '50%' }, + }, + { + name: 'test/state-button', + baseSelector: '.wp-elements-abc123', + state: ':hover', + } + ) + ).toBe( + '.wp-elements-abc123 .wp-block-button__link:hover { background-color: #ff00d0 !important; }\n.wp-elements-abc123:hover { width: 50% !important; }' + ); + } ); + + it( 'routes canvas preview styles through feature selectors without the pseudo state', () => { + expect( + getBlockStateStylesCSS( + { + color: { background: '#ff00d0' }, + dimensions: { width: '50%' }, + }, + { + name: 'test/state-button', + baseSelector: '[data-block="client-id"]', + } + ) + ).toBe( + '[data-block="client-id"] .wp-block-button__link { background-color: #ff00d0 !important; }\n[data-block="client-id"] { width: 50% !important; }' + ); + } ); +} ); + +describe( 'getResponsiveStateCSSRules', () => { + beforeEach( () => { + registerBlockType( 'test/button', { + apiVersion: 3, + title: 'Button', + category: 'text', + attributes: {}, + edit: () => null, + save: () => null, + selectors: { + root: '.wp-block-button .wp-block-button__link', + }, + } ); + } ); + + afterEach( () => { + unregisterBlockType( 'test/button' ); + } ); + + it( 'generates media-query scoped root styles for viewport states', () => { + expect( + getResponsiveStateCSSRules( + { + mobile: { + color: { text: 'red' }, + }, + }, + 'core/paragraph', + '.wp-elements-1' + ) + ).toEqual( [ + '@media (width <= 480px){.wp-elements-1 { color: red !important; }}', + ] ); + } ); + + it( 'generates media-query scoped root styles for descendant root selectors', () => { + expect( + getResponsiveStateCSSRules( + { + mobile: { + color: { text: 'red' }, + }, + }, + 'test/button', + '.wp-elements-1' + ) + ).toEqual( [ + '@media (width <= 480px){.wp-elements-1 .wp-block-button__link { color: red !important; }}', + ] ); + } ); + + it( 'generates media-query scoped pseudo styles for viewport states', () => { + expect( + getResponsiveStateCSSRules( + { + mobile: { + ':hover': { + color: { background: 'black' }, + }, + }, + }, + 'core/button', + '.wp-elements-1' + ) + ).toEqual( [ + '@media (width <= 480px){.wp-elements-1:hover { background-color: black !important; }}', + ] ); + } ); + + it( 'generates media-query scoped element styles for viewport states', () => { + expect( + getResponsiveStateCSSRules( + { + mobile: { + elements: { + link: { + color: { text: 'blue' }, + }, + }, + }, + }, + 'core/paragraph', + '.wp-elements-1' + ) + ).toEqual( [ + '@media (width <= 480px){.wp-elements-1 a:where(:not(.wp-element-button)) { color: blue; }}', + ] ); + } ); +} ); + +describe( 'getCanvasStateStyleValue', () => { + it( 'returns the selected pseudo state value without a viewport state', () => { + expect( + getCanvasStateStyleValue( + { + ':hover': { + color: { text: 'red' }, + }, + }, + { viewport: 'default', pseudo: ':hover' } + ) + ).toEqual( { + color: { text: 'red' }, + } ); + } ); + + it( 'falls back to default viewport pseudo styles for responsive pseudo states', () => { + expect( + getCanvasStateStyleValue( + { + ':hover': { + color: { text: 'red' }, + }, + }, + { viewport: 'mobile', pseudo: ':hover' } + ) + ).toEqual( { + color: { text: 'red' }, + } ); + } ); + + it( 'merges responsive pseudo styles over default viewport pseudo styles', () => { + expect( + getCanvasStateStyleValue( + { + ':hover': { + color: { background: 'blue', text: 'red' }, + }, + mobile: { + ':hover': { + color: { text: 'yellow' }, + }, + }, + }, + { viewport: 'mobile', pseudo: ':hover' } + ) + ).toEqual( { + color: { background: 'blue', text: 'yellow' }, + } ); + } ); +} ); + describe( 'addSaveProps', () => { const blockSettings = { save: () =>
, diff --git a/packages/block-editor/src/hooks/typography.js b/packages/block-editor/src/hooks/typography.js index f05e5d320b96c6..855cf686122290 100644 --- a/packages/block-editor/src/hooks/typography.js +++ b/packages/block-editor/src/hooks/typography.js @@ -23,6 +23,7 @@ import { cleanEmptyObject } from './utils'; import { store as blockEditorStore } from '../store'; import { getStyleForState, + isDefaultBlockStyleState, setStyleForState, useBlockStyleState, } from './block-style-state'; @@ -147,7 +148,7 @@ export function TypographyPanel( { clientId, name, setAttributes, settings } ) { [ clientId, isEnabled ] ); - const isStateSelected = selectedState !== 'default'; + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); const value = useMemo( () => { if ( isStateSelected ) { diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 2f3118ac42541e..34f1244ed8221e 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -46,6 +46,7 @@ import { mediaEditKey, getMediaSelectKey, deviceTypeKey, + onViewportStateChangeKey, isIsolatedEditorKey, isNavigationOverlayContextKey, isNavigationPostEditorKey, @@ -130,6 +131,7 @@ lock( privateApis, { mediaEditKey, getMediaSelectKey, deviceTypeKey, + onViewportStateChangeKey, isIsolatedEditorKey, isNavigationOverlayContextKey, isNavigationPostEditorKey, diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index 6c139f6f3b5851..d5a2f7ee0e9486 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -531,10 +531,10 @@ export function clearRequestedInspectorTab() { } /** - * Sets the selected pseudo-state for a block's style controls. + * Sets the selected style state for a block's style controls. * * @param {string} clientId The block client ID. - * @param {string} value The selected state value. + * @param {Object} value The selected state value. * * @return {Object} Action object. */ @@ -545,3 +545,19 @@ export function setSelectedBlockStyleState( clientId, value ) { value, }; } + +/** + * Sets whether the selected style state is shown on the canvas. + * + * @param {string} clientId The block client ID. + * @param {boolean} value Whether to show the selected state on the canvas. + * + * @return {Object} Action object. + */ +export function setSelectedBlockStyleStateCanvasPreview( clientId, value ) { + return { + type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW', + clientId, + value, + }; +} diff --git a/packages/block-editor/src/store/private-keys.js b/packages/block-editor/src/store/private-keys.js index cb849880a655ea..49275fb539dd0a 100644 --- a/packages/block-editor/src/store/private-keys.js +++ b/packages/block-editor/src/store/private-keys.js @@ -7,6 +7,7 @@ export const mediaEditKey = Symbol( 'mediaEditKey' ); export const getMediaSelectKey = Symbol( 'getMediaSelect' ); export const isIsolatedEditorKey = Symbol( 'isIsolatedEditor' ); export const deviceTypeKey = Symbol( 'deviceTypeKey' ); +export const onViewportStateChangeKey = Symbol( 'onViewportStateChangeKey' ); export const isNavigationOverlayContextKey = Symbol( 'isNavigationOverlayContext' ); diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 8c2029ac0ec214..3bf9064d4985c1 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -1053,18 +1053,39 @@ export function getRequestedInspectorTab( state ) { return state.requestedInspectorTab; } +const DEFAULT_BLOCK_STYLE_STATE = { + viewport: 'default', + pseudo: 'default', +}; + /** - * Returns the selected pseudo-state for a block's style controls. + * Returns the selected style state for a block's style controls. * * @param {Object} state Global application state. * @param {string} clientId The block client ID. * - * @return {string} The selected block style state. + * @return {Object} The selected block style state. */ export function getSelectedBlockStyleState( state, clientId ) { if ( state.selectedBlockStyleState?.clientId !== clientId ) { - return 'default'; + return DEFAULT_BLOCK_STYLE_STATE; + } + + return state.selectedBlockStyleState.value ?? DEFAULT_BLOCK_STYLE_STATE; +} + +/** + * Returns whether the selected style state is shown on the canvas. + * + * @param {Object} state Global application state. + * @param {string} clientId The block client ID. + * + * @return {boolean} Whether the selected style state is shown on the canvas. + */ +export function isSelectedBlockStyleStateShownOnCanvas( state, clientId ) { + if ( state.selectedBlockStyleState?.clientId !== clientId ) { + return true; } - return state.selectedBlockStyleState.value ?? 'default'; + return state.selectedBlockStyleState.showStateOnCanvas ?? true; } diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 6024a946db9437..c4ac46b27194bf 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -2276,7 +2276,7 @@ export function requestedInspectorTab( state = null, action ) { } /** - * Reducer tracking the selected pseudo-state for block style controls. + * Reducer tracking the selected style state for block style controls. * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -2286,17 +2286,44 @@ export function requestedInspectorTab( state = null, action ) { export function selectedBlockStyleState( state = undefined, action ) { switch ( action.type ) { case 'SET_SELECTED_BLOCK_STYLE_STATE': { - if ( - ! action.clientId || - ! action.value || - action.value === 'default' - ) { + if ( ! action.clientId || ! action.value ) { return undefined; } + const showStateOnCanvas = + state?.clientId === action.clientId + ? state.showStateOnCanvas ?? true + : true; + const previousValue = + state?.clientId === action.clientId ? state.value : {}; return { clientId: action.clientId, - value: action.value, + showStateOnCanvas, + value: { + viewport: 'default', + pseudo: 'default', + ...previousValue, + ...action.value, + }, + }; + } + + case 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW': { + if ( ! action.clientId || typeof action.value !== 'boolean' ) { + return state; + } + + const previousValue = + state?.clientId === action.clientId ? state.value : {}; + + return { + clientId: action.clientId, + showStateOnCanvas: action.value, + value: { + viewport: 'default', + pseudo: 'default', + ...previousValue, + }, }; } diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js index 5fbfd5edd2dee8..9d1f67971f45bc 100644 --- a/packages/block-editor/src/store/test/private-actions.js +++ b/packages/block-editor/src/store/test/private-actions.js @@ -12,6 +12,7 @@ import { showViewportModal, hideViewportModal, setSelectedBlockStyleState, + setSelectedBlockStyleStateCanvasPreview, } from '../private-actions'; describe( 'private actions', () => { @@ -146,11 +147,23 @@ describe( 'private actions', () => { describe( 'setSelectedBlockStyleState', () => { it( 'returns the SET_SELECTED_BLOCK_STYLE_STATE action', () => { expect( - setSelectedBlockStyleState( 'client-1', ':hover' ) + setSelectedBlockStyleState( 'client-1', { pseudo: ':hover' } ) ).toEqual( { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-1', - value: ':hover', + value: { pseudo: ':hover' }, + } ); + } ); + } ); + + describe( 'setSelectedBlockStyleStateCanvasPreview', () => { + it( 'returns the SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW action', () => { + expect( + setSelectedBlockStyleStateCanvasPreview( 'client-1', false ) + ).toEqual( { + type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW', + clientId: 'client-1', + value: false, } ); } ); } ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index ad48fb91b795f7..7a4006ceade5a5 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -25,6 +25,7 @@ import { isSectionBlock, getParentSectionBlock, getSelectedBlockStyleState, + isSelectedBlockStyleStateShownOnCanvas, } from '../private-selectors'; import { getBlockEditingMode } from '../selectors'; import { deviceTypeKey } from '../private-keys'; @@ -77,22 +78,24 @@ describe( 'private selectors', () => { it( 'returns default when the block has no selected state', () => { const state = {}; - expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe( - 'default' - ); + expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( { + viewport: 'default', + pseudo: 'default', + } ); } ); it( 'returns the selected state for the block', () => { const state = { selectedBlockStyleState: { clientId: 'client-1', - value: ':hover', + value: { viewport: 'mobile', pseudo: ':hover' }, }, }; - expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe( - ':hover' - ); + expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( { + viewport: 'mobile', + pseudo: ':hover', + } ); } ); it( 'returns default when the selected state has no value', () => { @@ -102,22 +105,60 @@ describe( 'private selectors', () => { }, }; - expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe( - 'default' - ); + expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( { + viewport: 'default', + pseudo: 'default', + } ); } ); it( 'returns default when another block has the selected state', () => { const state = { selectedBlockStyleState: { clientId: 'client-2', - value: ':hover', + value: { viewport: 'default', pseudo: ':hover' }, }, }; - expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe( - 'default' - ); + expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( { + viewport: 'default', + pseudo: 'default', + } ); + } ); + } ); + + describe( 'isSelectedBlockStyleStateShownOnCanvas', () => { + it( 'returns true when the block has no canvas preview state', () => { + const state = {}; + + expect( + isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' ) + ).toBe( true ); + } ); + + it( 'returns the canvas preview state for the block', () => { + const state = { + selectedBlockStyleState: { + clientId: 'client-1', + showStateOnCanvas: false, + }, + }; + + expect( + isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' ) + ).toBe( false ); + } ); + + it( 'returns true when another block has canvas preview state', () => { + const state = { + selectedBlockStyleState: { + clientId: 'client-2', + showStateOnCanvas: false, + }, + }; + + expect( + isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' ) + ).toBe( true ); } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 33f9b66225b265..beadf663ca884b 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -5823,54 +5823,118 @@ describe( 'state', () => { expect( state ).toBeUndefined(); } ); - it( 'stores a selected state for a block', () => { + it( 'stores a selected viewport state for a block', () => { const state = selectedBlockStyleState( undefined, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-1', - value: ':hover', + value: { viewport: 'mobile' }, } ); expect( state ).toEqual( { clientId: 'client-1', - value: ':hover', + showStateOnCanvas: true, + value: { + viewport: 'mobile', + pseudo: 'default', + }, + } ); + } ); + + it( 'stores a selected pseudo state for a block', () => { + const state = selectedBlockStyleState( undefined, { + type: 'SET_SELECTED_BLOCK_STYLE_STATE', + clientId: 'client-1', + value: { pseudo: ':hover' }, + } ); + + expect( state ).toEqual( { + clientId: 'client-1', + showStateOnCanvas: true, + value: { + viewport: 'default', + pseudo: ':hover', + }, + } ); + } ); + + it( 'updates only the selected state type for the same block', () => { + const state = selectedBlockStyleState( + { + clientId: 'client-1', + value: { viewport: 'mobile', pseudo: 'default' }, + }, + { + type: 'SET_SELECTED_BLOCK_STYLE_STATE', + clientId: 'client-1', + value: { pseudo: ':hover' }, + } + ); + + expect( state ).toEqual( { + clientId: 'client-1', + showStateOnCanvas: true, + value: { + viewport: 'mobile', + pseudo: ':hover', + }, } ); } ); it( 'replaces the selected state when another block is selected', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'mobile', pseudo: ':hover' }, + }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-2', - value: ':focus', + value: { pseudo: ':focus' }, } ); expect( state ).toEqual( { clientId: 'client-2', - value: ':focus', + showStateOnCanvas: true, + value: { + viewport: 'default', + pseudo: ':focus', + }, } ); } ); - it( 'removes the selected state when default is selected', () => { + it( 'stores explicit defaults when both state types are default', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'mobile', pseudo: ':hover' }, + }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-1', - value: 'default', + value: { viewport: 'default', pseudo: 'default' }, } ); - expect( state ).toBeUndefined(); + expect( state ).toEqual( { + clientId: 'client-1', + showStateOnCanvas: true, + value: { + viewport: 'default', + pseudo: 'default', + }, + } ); } ); it( 'clears the selected state when clientId is missing', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', - value: ':focus', + value: { pseudo: ':focus' }, } ); @@ -5879,7 +5943,10 @@ describe( 'state', () => { it( 'clears the selected state when value is missing', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-1', @@ -5890,7 +5957,10 @@ describe( 'state', () => { } ); it( 'keeps the selected state when the same block is selected', () => { - const originalState = { clientId: 'client-1', value: ':hover' }; + const originalState = { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }; const state = selectedBlockStyleState( originalState, { type: 'SELECT_BLOCK', clientId: 'client-1', @@ -5901,7 +5971,10 @@ describe( 'state', () => { it( 'clears the selected state when another block is selected', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'SELECT_BLOCK', clientId: 'client-2', @@ -5912,7 +5985,10 @@ describe( 'state', () => { } ); it( 'keeps the selected state for selection changes in the same block', () => { - const originalState = { clientId: 'client-1', value: ':hover' }; + const originalState = { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }; const state = selectedBlockStyleState( originalState, { type: 'SELECTION_CHANGE', clientId: 'client-1', @@ -5923,7 +5999,10 @@ describe( 'state', () => { it( 'clears the selected state for selection changes in another block', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'SELECTION_CHANGE', clientId: 'client-2', @@ -5934,7 +6013,10 @@ describe( 'state', () => { } ); it( 'keeps the selected state when selection resets to the same block', () => { - const originalState = { clientId: 'client-1', value: ':hover' }; + const originalState = { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }; const state = selectedBlockStyleState( originalState, { type: 'RESET_SELECTION', selectionStart: { clientId: 'client-1' }, @@ -5945,7 +6027,10 @@ describe( 'state', () => { it( 'clears the selected state when selection resets to another block', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'RESET_SELECTION', selectionStart: { clientId: 'client-2' }, @@ -5957,7 +6042,10 @@ describe( 'state', () => { it( 'clears the selected state when the selection is cleared', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'CLEAR_SELECTED_BLOCK', } @@ -5968,7 +6056,10 @@ describe( 'state', () => { it( 'clears the selected state when multiple blocks are selected', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'MULTI_SELECT', } @@ -5979,7 +6070,10 @@ describe( 'state', () => { it( 'clears the selected state when the block is removed', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'REMOVE_BLOCKS', clientIds: [ 'client-1' ], @@ -5991,7 +6085,10 @@ describe( 'state', () => { it( 'clears the selected state when the block is replaced', () => { const state = selectedBlockStyleState( - { clientId: 'client-2', value: ':focus' }, + { + clientId: 'client-2', + value: { viewport: 'default', pseudo: ':focus' }, + }, { type: 'REPLACE_BLOCKS', clientIds: [ 'client-2' ], @@ -6003,7 +6100,10 @@ describe( 'state', () => { it( 'clears the selected state when the block is missing after reset', () => { const state = selectedBlockStyleState( - { clientId: 'client-1', value: ':hover' }, + { + clientId: 'client-1', + value: { viewport: 'default', pseudo: ':hover' }, + }, { type: 'RESET_BLOCKS', blocks: [ @@ -6019,7 +6119,10 @@ describe( 'state', () => { } ); it( 'keeps the selected state when the block exists after reset', () => { - const originalState = { clientId: 'client-2', value: ':focus' }; + const originalState = { + clientId: 'client-2', + value: { viewport: 'default', pseudo: ':focus' }, + }; const state = selectedBlockStyleState( originalState, { type: 'RESET_BLOCKS', blocks: [ @@ -6032,6 +6135,47 @@ describe( 'state', () => { expect( state ).toBe( originalState ); } ); + + it( 'stores whether canvas preview is enabled for the selected state', () => { + const state = selectedBlockStyleState( + { + clientId: 'client-1', + value: { viewport: 'mobile', pseudo: ':hover' }, + }, + { + type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW', + clientId: 'client-1', + value: false, + } + ); + + expect( state ).toEqual( { + clientId: 'client-1', + showStateOnCanvas: false, + value: { viewport: 'mobile', pseudo: ':hover' }, + } ); + } ); + + it( 'keeps canvas preview when updating the selected state for the same block', () => { + const state = selectedBlockStyleState( + { + clientId: 'client-1', + showStateOnCanvas: false, + value: { viewport: 'mobile', pseudo: 'default' }, + }, + { + type: 'SET_SELECTED_BLOCK_STYLE_STATE', + clientId: 'client-1', + value: { pseudo: ':hover' }, + } + ); + + expect( state ).toEqual( { + clientId: 'client-1', + showStateOnCanvas: false, + value: { viewport: 'mobile', pseudo: ':hover' }, + } ); + } ); } ); describe( 'viewportModalClientIds', () => { diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 3d10c78ce1795a..a7a9a3cc9d5ad4 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -107,6 +107,7 @@ const { getMediaSelectKey, isIsolatedEditorKey, deviceTypeKey, + onViewportStateChangeKey, isNavigationOverlayContextKey, isNavigationPostEditorKey, mediaUploadOnSuccessKey, @@ -271,6 +272,9 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { ); const { undo, setIsInserterOpened } = useDispatch( editorStore ); + const { updateDeviceTypeForViewportState } = unlock( + useDispatch( editorStore ) + ); const { editMediaEntity } = unlock( useDispatch( coreStore ) ); const { saveEntityRecord } = useDispatch( coreStore ); const { openMediaEditorModal } = useDispatch( mediaEditorStore ); @@ -409,6 +413,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { disableContentOnlyForTemplateParts: renderingMode === 'template-locked', ...( deviceType ? { [ deviceTypeKey ]: deviceType } : {} ), + [ onViewportStateChangeKey ]: updateDeviceTypeForViewportState, [ isNavigationOverlayContextKey ]: isNavigationOverlayContext, }; @@ -445,7 +450,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { renderingMode, editMediaEntity, openMediaEditorModal, - settings.onNavigateToEntityRecord, + updateDeviceTypeForViewportState, deviceType, allImageSizes, bigImageSizeThreshold, diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index 097357bac06024..a953b2ba465217 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -17,8 +17,35 @@ import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; */ import isTemplateRevertable from './utils/is-template-revertable'; import { buildRevisionsPageQuery } from './private-selectors'; +import { unlock } from '../lock-unlock'; export * from '../dataviews/store/private-actions'; +const DEVICE_TYPE_BY_VIEWPORT_STATE = { + mobile: 'Mobile', + tablet: 'Tablet', +}; + +/** + * Updates the editor preview device in response to a block-editor viewport + * state signal. + * + * @param {Object} options Viewport state change options. + * @param {string} options.viewport Selected viewport state. + * @param {boolean} options.showStateOnCanvas Whether canvas preview is enabled. + */ +export const updateDeviceTypeForViewportState = + ( { viewport = 'default', showStateOnCanvas = true } = {} ) => + ( { dispatch, registry } ) => { + if ( ! showStateOnCanvas ) { + return; + } + + dispatch.setDeviceType( + DEVICE_TYPE_BY_VIEWPORT_STATE[ viewport ] ?? 'Desktop' + ); + unlock( registry.dispatch( blockEditorStore ) ).resetZoomLevel(); + }; + /** * Returns an action object used to set which template is currently being used/edited. * diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 206c60a159d04f..dfbfbedda1b9ee 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -14,6 +14,7 @@ import { store as preferencesStore } from '@wordpress/preferences'; import * as actions from '../actions'; import { store as editorStore } from '..'; +import { unlock } from '../../lock-unlock'; const postId = 44; @@ -63,6 +64,39 @@ const getMethod = ( options ) => options.headers?.[ 'X-HTTP-Method-Override' ] || options.method || 'GET'; describe( 'Post actions', () => { + describe( 'updateDeviceTypeForViewportState', () => { + it( 'updates the editor device type for a viewport state', () => { + const registry = createRegistryWithStores(); + + unlock( + registry.dispatch( editorStore ) + ).updateDeviceTypeForViewportState( { + viewport: 'mobile', + showStateOnCanvas: true, + } ); + + expect( registry.select( editorStore ).getDeviceType() ).toBe( + 'Mobile' + ); + } ); + + it( 'keeps the editor device type when canvas preview is disabled', () => { + const registry = createRegistryWithStores(); + registry.dispatch( editorStore ).setDeviceType( 'Tablet' ); + + unlock( + registry.dispatch( editorStore ) + ).updateDeviceTypeForViewportState( { + viewport: 'mobile', + showStateOnCanvas: false, + } ); + + expect( registry.select( editorStore ).getDeviceType() ).toBe( + 'Tablet' + ); + } ); + } ); + describe( 'savePost()', () => { it( 'saves a modified post', async () => { const post = { diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php index 3de567347dd670..a9d8068a1509a9 100644 --- a/phpunit/block-supports/states-test.php +++ b/phpunit/block-supports/states-test.php @@ -55,29 +55,21 @@ private function ensure_block_registered( $block_name, $selectors = array() ) { } /** - * Mirrors the CSS-building logic in gutenberg_render_block_states_support() - * to produce the unique scoped class name for a given map of state => style arrays. + * Mirrors the selector-aware state CSS-building logic in + * gutenberg_render_block_states_support() to produce the unique scoped class + * name for a given map of state => style arrays. * CSS is now registered with the style engine store rather than injected inline. * - * @param array $state_styles Map of state to style array (e.g. `[':hover' => ['color' => [...]]]`). + * @param array $state_styles Map of state to style array (e.g. `[':hover' => ['color' => [...]]]`). + * @param string $block_name Block name. * @return array { unique_class: string } */ - private function build_expected_state_output( $state_styles ) { - $css_rules = array(); - foreach ( $state_styles as $state => $style ) { - $compiled = wp_style_engine_get_styles( - gutenberg_normalize_state_style_for_css_output( $style ) - ); - if ( ! empty( $compiled['declarations'] ) ) { - $css_rules[] = array( - 'state' => $state, - 'declarations' => $compiled['declarations'], - ); - } - } + private function build_expected_state_output( $state_styles, $block_name = 'core/navigation-link' ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); + $css_rules = gutenberg_get_block_state_style_rules( $state_styles, $block_type ); return array( - 'unique_class' => 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 ), + 'unique_class' => gutenberg_get_block_state_unique_class( $block_name, $css_rules ), ); } @@ -652,6 +644,77 @@ public function test_unconfigured_pseudo_state_is_ignored() { $this->assertSame( $block_content, $actual ); } + /** + * Tests that a responsive root state generates media-query scoped CSS. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_responsive_root_state_generates_media_query_scoped_css() { + $this->ensure_block_registered( 'core/paragraph' ); + + $block_content = '

Hello

'; + $block = array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'style' => array( + 'mobile' => array( 'color' => array( 'text' => '#ff0000' ) ), + ), + ), + ); + + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertMatchesRegularExpression( + '/^

Hello<\/p>$/', + $actual + ); + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $matches[0] . '{color:#ff0000 !important;}}', + $actual_stylesheet + ); + } + + /** + * Tests that a responsive pseudo-state generates media-query scoped CSS. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_responsive_pseudo_state_generates_media_query_scoped_css() { + $this->ensure_block_registered( + 'core/button', + array( 'root' => '.wp-block-button .wp-block-button__link' ) + ); + + $block_content = '

'; + $block = array( + 'blockName' => 'core/button', + 'attrs' => array( + 'style' => array( + 'mobile' => array( + ':hover' => array( 'color' => array( 'background' => '#ff00d0' ) ), + ), + ), + ), + ); + + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertMatchesRegularExpression( + '/^
Click me<\/a><\/div>$/', + $actual + ); + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $matches[0] . ' .wp-block-button__link:hover{background-color:#ff00d0 !important;}}', + $actual_stylesheet + ); + } + /** * Tests that the unique scoped class is added to the wrapper element for a * block with no descendant root selector. @@ -676,13 +739,12 @@ public function test_unique_class_is_added_to_wrapper_when_no_root_selector() { } /** - * Tests that the unique scoped class is added to the descendant element (not - * the wrapper) for a block whose `selectors.root` targets a descendant, so - * that `.wp-states-XXXX:hover` matches correctly. + * Tests that the unique scoped class is added to the wrapper for a block + * whose `selectors.root` targets a descendant. * * @covers ::gutenberg_render_block_states_support */ - public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_selector_has_descendant() { + public function test_unique_class_is_added_to_wrapper_when_root_selector_has_descendant() { $this->ensure_block_registered( 'core/button', array( 'root' => '.wp-block-button .wp-block-button__link' ) @@ -695,8 +757,8 @@ public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_s 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); @@ -706,7 +768,7 @@ public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_s * Integration test using the exact block markup and style attribute captured * from a core/button block in the editor with Twenty Twenty-Four theme. * Covers color, typography (preset font family reference), and class injection - * onto the descendant element. + * onto the wrapper element. * * @covers ::gutenberg_render_block_states_support */ @@ -734,8 +796,8 @@ public function test_button_like_block_with_hover_color_and_font_family_preset() 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); @@ -768,10 +830,57 @@ public function test_button_like_block_with_hover_border() { 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); } + + /** + * Tests that button hover width is scoped to the outer wrapper while visual + * styles remain scoped to the inner element. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_button_like_block_with_hover_width_targets_wrapper() { + $this->ensure_block_registered( + 'core/button', + array( + 'root' => '.wp-block-button .wp-block-button__link', + 'dimensions' => array( + 'root' => '.wp-block-button', + 'width' => '.wp-block-button', + ), + ) + ); + + $block_content = ''; + $state_styles = array( + ':hover' => array( + 'color' => array( 'background' => '#ff00d0' ), + 'dimensions' => array( 'width' => '50%' ), + ), + ); + $block = array( + 'blockName' => 'core/button', + 'attrs' => array( 'style' => $state_styles ), + ); + + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertSame( $expected, $actual ); + + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + $this->assertStringContainsString( + '.' . $parts['unique_class'] . ':hover{width:50% !important;}', + $actual_stylesheet + ); + $this->assertStringContainsString( + '.' . $parts['unique_class'] . ' .wp-block-button__link:hover{background-color:#ff00d0 !important;}', + $actual_stylesheet + ); + } } diff --git a/phpunit/style-engine/style-engine-test.php b/phpunit/style-engine/style-engine-test.php index 57fbc073cc07eb..289c170bda282a 100644 --- a/phpunit/style-engine/style-engine-test.php +++ b/phpunit/style-engine/style-engine-test.php @@ -195,6 +195,21 @@ public function data_wp_style_engine_get_styles() { ), ), + 'inline_valid_dimensions_width_style' => array( + 'block_styles' => array( + 'dimensions' => array( + 'width' => '50%', + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'width:50%;', + 'declarations' => array( + 'width' => '50%', + ), + ), + ), + 'inline_valid_typography_style' => array( 'block_styles' => array( 'typography' => array(