From 5c7e87f5ffc63bc062a6db4fe36fa87cea4edfec Mon Sep 17 00:00:00 2001 From: Shail Mehta Date: Tue, 11 Aug 2026 22:02:58 +0530 Subject: [PATCH 1/3] Style States: Allow unsetting background image per viewport --- packages/block-editor/CHANGELOG.md | 1 + .../background-image-control/index.js | 67 ++++++++-- .../global-styles/background-panel.js | 19 ++- .../global-styles/test/background-panel.js | 59 ++++++++ .../src/components/inspector-controls/fill.js | 19 ++- packages/block-editor/src/hooks/background.js | 126 ++++++++++++++++-- .../block-editor/src/hooks/test/background.js | 54 ++++++++ 7 files changed, 318 insertions(+), 27 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index becb51cffe9c25..92b6768283969f 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -36,6 +36,7 @@ - Background block support: Fix gradients not being applied to a block when a theme opts out of `settings.background.gradient` in `theme.json` ([#81056](https://github.com/WordPress/gutenberg/pull/81056)). - `LinkControl`: Restore the preview title underline by slightly increasing the title's line height, which was too tight for the underline to be visible ([#81083](https://github.com/WordPress/gutenberg/pull/81083)). - `URLInput`: Skip link search requests while an IME composition is in progress; the search now fires once with the confirmed value on `compositionend` ([#80602](https://github.com/WordPress/gutenberg/pull/80602)). +- Style states: Allow unsetting a background image in a viewport or pseudo state by persisting `background-image: none` and exposing the default-state image as inherited ([#80388](https://github.com/WordPress/gutenberg/issues/80388)). ## 16.1.0 (2026-07-29) diff --git a/packages/block-editor/src/components/background-image-control/index.js b/packages/block-editor/src/components/background-image-control/index.js index 0c06b6cb18cf1a..4d73d0dbc64bb3 100644 --- a/packages/block-editor/src/components/background-image-control/index.js +++ b/packages/block-editor/src/components/background-image-control/index.js @@ -657,6 +657,12 @@ export default function BackgroundImagePanel( { settings, defaultValues = {}, showInheritanceLabelIndicators = isGlobalStylesInheritanceEnabled(), + /** + * When true, media Reset writes an explicit `backgroundImage: 'none'` + * instead of clearing the value. Needed for viewport/pseudo style states + * so the default-state image does not return through the cascade. + */ + persistImageUnsetOnReset = false, } ) { /* * Resolve inherited `ref` pointers for background controls. @@ -692,19 +698,39 @@ export default function BackgroundImagePanel( { return resolvedValues; }, [ globalStyles, _links, inheritedValue ] ); - const resetBackground = () => + // Clear local override so the inherited/default-state image returns. + const clearBackgroundImage = () => onChange( setImmutably( value, [ 'background' ], { gradient: value?.background?.gradient, } ) ); - const { title, url } = value?.background?.backgroundImage || { - ...resolvedInheritedValue?.background?.backgroundImage, - }; + // Explicitly unset for the current style state. + const unsetBackgroundImage = () => + onChange( + setImmutably( value, [ 'background' ], { + gradient: value?.background?.gradient, + backgroundImage: 'none', + } ) + ); + + const resetMediaImage = persistImageUnsetOnReset + ? unsetBackgroundImage + : clearBackgroundImage; + + const localBackgroundImage = value?.background?.backgroundImage; + const hasExplicitImageUnset = localBackgroundImage === 'none'; + const { title, url } = hasExplicitImageUnset + ? {} + : localBackgroundImage || { + ...resolvedInheritedValue?.background?.backgroundImage, + }; const localHasImageValue = hasBackgroundImageValue( value ); const hasImageValue = - localHasImageValue || hasBackgroundImageValue( resolvedInheritedValue ); + ! hasExplicitImageUnset && + ( localHasImageValue || + hasBackgroundImageValue( resolvedInheritedValue ) ); // The blue-dot local-override affordance is part of the inherited-value // treatment. When that treatment is disabled (e.g. in the Global Styles // panel, where the edited value *is* the global style rather than a local @@ -714,9 +740,10 @@ export default function BackgroundImagePanel( { localHasImageValue && hasBackgroundImageValue( resolvedInheritedValue ); - const imageValue = - value?.background?.backgroundImage || - inheritedValue?.background?.backgroundImage; + const imageValue = hasExplicitImageUnset + ? 'none' + : localBackgroundImage || + inheritedValue?.background?.backgroundImage; const shouldShowBackgroundImageControls = hasImageValue && @@ -746,7 +773,9 @@ export default function BackgroundImagePanel( { onToggle={ setIsDropDownOpen } hasImageValue={ hasImageValue } hasLocalOverride={ hasLocalOverride } - onReset={ localHasImageValue ? resetBackground : undefined } + onReset={ + localHasImageValue ? clearBackgroundImage : undefined + } containerRef={ containerRef } > @@ -757,7 +786,7 @@ export default function BackgroundImagePanel( { displayInPanel onResetImage={ () => { setIsDropDownOpen( false ); - resetBackground(); + resetMediaImage(); } } onRemoveImage={ () => setIsDropDownOpen( false ) } defaultValues={ defaultValues } @@ -774,12 +803,24 @@ export default function BackgroundImagePanel( { ) : ( { setIsDropDownOpen( false ); - resetBackground(); + resetMediaImage(); } } onRemoveImage={ () => setIsDropDownOpen( false ) } containerRef={ containerRef } diff --git a/packages/block-editor/src/components/global-styles/background-panel.js b/packages/block-editor/src/components/global-styles/background-panel.js index d1b5d1de3eaa71..132300cf402071 100644 --- a/packages/block-editor/src/components/global-styles/background-panel.js +++ b/packages/block-editor/src/components/global-styles/background-panel.js @@ -16,6 +16,10 @@ import { InheritanceToolsPanelItem, isGlobalStylesInheritanceEnabled, } from './inheritance'; +import { + DEFAULT_BLOCK_STYLE_STATE, + isDefaultBlockStyleState, +} from '../../hooks/block-style-state'; const DEFAULT_CONTROLS = { backgroundImage: true, @@ -164,7 +168,9 @@ export default function BackgroundImagePanel( { headerLabel = __( 'Background' ), contrastWarning, showInheritanceLabelIndicators = isGlobalStylesInheritanceEnabled(), + styleState = DEFAULT_BLOCK_STYLE_STATE, } ) { + const isStyleStateSelected = ! isDefaultBlockStyleState( styleState ); const { colors, gradients, @@ -207,12 +213,17 @@ export default function BackgroundImagePanel( { const clearsColorBackground = showBackgroundColorControl; const clearsColorGradient = hasBackgroundGradientControl || showLegacyColorGradientControl; + // Persist an explicit unset under a style state so the default-state + // background image does not return through the cascade. + const background = isStyleStateSelected + ? { backgroundImage: 'none' } + : {}; if ( ! clearsColorBackground && ! clearsColorGradient ) { - return { ...previousValue, background: {} }; + return { ...previousValue, background }; } return { ...previousValue, - background: {}, + background, color: { ...previousValue?.color, ...( clearsColorBackground && { background: undefined } ), @@ -222,6 +233,7 @@ export default function BackgroundImagePanel( { }, [ hasBackgroundGradientControl, + isStyleStateSelected, showBackgroundColorControl, showLegacyColorGradientControl, ] @@ -241,7 +253,7 @@ export default function BackgroundImagePanel( { setImmutably( value, [ 'background', 'backgroundImage' ], - undefined + isStyleStateSelected ? 'none' : undefined ) ); @@ -373,6 +385,7 @@ export default function BackgroundImagePanel( { showInheritanceLabelIndicators={ showInheritanceLabelIndicators } + persistImageUnsetOnReset={ isStyleStateSelected } /> ) } diff --git a/packages/block-editor/src/components/global-styles/test/background-panel.js b/packages/block-editor/src/components/global-styles/test/background-panel.js index db15f4b5363011..02c70ebe53de1d 100644 --- a/packages/block-editor/src/components/global-styles/test/background-panel.js +++ b/packages/block-editor/src/components/global-styles/test/background-panel.js @@ -50,6 +50,65 @@ describe( 'hasBackgroundImageValue', () => { } ) ).toBe( false ); } ); + + it( 'should return `true` for an explicit none unset value', () => { + expect( + hasBackgroundImageValue( { + background: { backgroundImage: 'none' }, + } ) + ).toBe( true ); + } ); +} ); + +describe( 'BackgroundPanel — style state background image reset', () => { + it( 'writes background-image none when deselecting under a viewport state', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByRole( 'button', { name: /Background options/i } ) + ); + await user.click( + screen.getByRole( 'menuitem', { name: /Reset Image/i } ) + ); + + expect( onChange ).toHaveBeenCalledWith( + expect.objectContaining( { + background: expect.objectContaining( { + backgroundImage: 'none', + } ), + } ) + ); + } ); } ); describe( 'hasBackgroundGradientValue', () => { diff --git a/packages/block-editor/src/components/inspector-controls/fill.js b/packages/block-editor/src/components/inspector-controls/fill.js index 73913bb828be26..37c5c71db59365 100644 --- a/packages/block-editor/src/components/inspector-controls/fill.js +++ b/packages/block-editor/src/components/inspector-controls/fill.js @@ -5,6 +5,7 @@ import { import warning from '@wordpress/warning'; import deprecated from '@wordpress/deprecated'; import { useEffect, useContext, useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; import { useBlockEditContext, mayDisplayControlsKey, @@ -13,10 +14,12 @@ import { } from '../block-edit/context'; import groups from './groups'; import { + DEFAULT_BLOCK_STYLE_STATE, scopeResetAllFilterToState, - useBlockStyleState, } from '../../hooks/block-style-state'; import { ListViewContentFill } from './list-view-content-popover'; +import { store as blockEditorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; const PATTERN_EDITING_GROUPS = [ 'content', 'list' ]; @@ -104,7 +107,19 @@ export default function InspectorControlsFill( { function RegisterResetAll( { resetAllFilter, children } ) { const { registerResetAllFilter, deregisterResetAllFilter } = useContext( ToolsPanelContext ); - const selectedState = useBlockStyleState(); + // Read from the store rather than BlockStyleState context. Fill content is + // portaled into the inspector slot, and relying on context here can miss + // the selected viewport — unscoped resets then clear default-state styles. + const selectedState = useSelect( ( select ) => { + const { getSelectedBlockClientId } = select( blockEditorStore ); + const { getSelectedBlockStyleState } = unlock( + select( blockEditorStore ) + ); + const clientId = getSelectedBlockClientId(); + return clientId + ? getSelectedBlockStyleState( clientId ) + : DEFAULT_BLOCK_STYLE_STATE; + }, [] ); const scopedResetAllFilter = useMemo( () => scopeResetAllFilterToState( selectedState, resetAllFilter ), [ resetAllFilter, selectedState ] diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 7dcd3dda749ee7..3293309accb4f3 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -1,6 +1,7 @@ import clsx from 'clsx'; import { getBlockSupport } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; +import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import InspectorControls from '../components/inspector-controls'; import { cleanEmptyObject } from './utils'; @@ -111,8 +112,11 @@ function useBlockProps( { name, style } ) { * @return {string} CSS class name. */ export function getBackgroundImageClasses( style ) { - return hasBackgroundImageValue( style ) || - hasBackgroundGradientValue( style ) + const backgroundImage = style?.background?.backgroundImage; + const hasImage = + hasBackgroundImageValue( style ) && backgroundImage !== 'none'; + + return hasImage || hasBackgroundGradientValue( style ) ? 'has-background' : ''; } @@ -146,11 +150,92 @@ export function backgroundResetAllFilter( attributes ) { }; } +/** + * Returns whether a style object still contains viewport/pseudo state keys. + * + * @param {Object} style Style object. + * @return {boolean} Whether the style looks like a full block style object. + */ +function styleHasStateKeys( style ) { + return ( + !! style && + Object.keys( style ).some( + ( key ) => key.startsWith( '@' ) || key.startsWith( ':' ) + ) + ); +} + +/** + * Persists an explicit background-image unset for a style-state style slice. + * Used when `scopeResetAllFilterToState` has already scoped the attributes to + * the selected viewport/pseudo state. + * + * @param {Object} attributes Attributes whose `style` is the state slice. + * @return {Object} Attribute updates for the scoped reset. + */ +export function backgroundStateResetAllFilter( attributes ) { + return { + style: cleanEmptyObject( { + ...attributes.style, + background: { + backgroundImage: 'none', + }, + color: { + ...attributes.style?.color, + background: undefined, + gradient: undefined, + }, + } ), + }; +} + +/** + * Resets background values for the selected style state without clearing + * default-state background image/color on other viewports. + * + * @param {Object} attributes Block attributes (full or already scoped). + * @param {Object} selectedState Selected block style state. + * @return {Object} Attribute updates. + */ +export function backgroundSelectedStateResetAllFilter( + attributes, + selectedState +) { + const style = attributes?.style; + const stateStyle = styleHasStateKeys( style ) + ? getStyleForState( style, selectedState ) || {} + : style || {}; + const nextStateStyle = backgroundStateResetAllFilter( { + style: stateStyle, + } ).style; + + if ( styleHasStateKeys( style ) ) { + return { + style: setStyleForState( style, selectedState, nextStateStyle ), + }; + } + + return { style: nextStateStyle }; +} + function BackgroundInspectorControl( { children } ) { + const selectedState = useBlockStyleState(); + const isStateSelected = ! isDefaultBlockStyleState( selectedState ); + const resetAllFilter = useCallback( + ( attributes ) => + isStateSelected + ? backgroundSelectedStateResetAllFilter( + attributes, + selectedState + ) + : backgroundResetAllFilter( attributes ), + [ isStateSelected, selectedState ] + ); + return ( { children } @@ -227,6 +312,32 @@ export function BackgroundImagePanel( { ? getStyleForState( style, selectedState ) : styleValue; + // Under a viewport/pseudo state, the block's default-state background is + // part of the cascade the control must expose as inherited. Without it, + // Remove never appears and clearing the control leaves the desktop image. + const panelInheritedValue = useMemo( () => { + if ( ! isStateSelected ) { + return inheritedValue; + } + + return { + ...inheritedValue, + background: { + ...inheritedValue?.background, + ...style?.background, + }, + color: { + ...inheritedValue?.color, + ...( styleValue?.color?.background !== undefined && { + background: styleValue.color.background, + } ), + ...( styleValue?.color?.gradient !== undefined && { + gradient: styleValue.color.gradient, + } ), + }, + }; + }, [ inheritedValue, isStateSelected, style?.background, styleValue ] ); + // Skipped for gradients, which can't be reliably evaluated for contrast. const enableContrastChecking = ! value?.color?.gradient && @@ -376,13 +487,10 @@ export function BackgroundImagePanel( { settings={ updatedSettings } onChange={ onChange } defaultControls={ defaultControls } - value={ - isStateSelected - ? getStyleForState( style, selectedState ) - : styleValue - } + value={ value } contrastWarning={ contrastWarning } - inheritedValue={ inheritedValue } + inheritedValue={ panelInheritedValue } + styleState={ selectedState } /> ); } diff --git a/packages/block-editor/src/hooks/test/background.js b/packages/block-editor/src/hooks/test/background.js index 69eb68723bf50b..af141afdd24829 100644 --- a/packages/block-editor/src/hooks/test/background.js +++ b/packages/block-editor/src/hooks/test/background.js @@ -4,12 +4,66 @@ import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; import { setBackgroundStyleDefaults, backgroundResetAllFilter, + backgroundStateResetAllFilter, + backgroundSelectedStateResetAllFilter, BackgroundImagePanel, BACKGROUND_BLOCK_DEFAULT_VALUES, } from '../background'; import { BackgroundToolsPanel } from '../../components/global-styles/background-panel'; describe( 'background', () => { + describe( 'backgroundStateResetAllFilter', () => { + it( 'persists background-image none for a scoped style-state reset', () => { + const result = backgroundStateResetAllFilter( { + style: { + background: { + backgroundImage: { url: 'mobile.png' }, + backgroundSize: 'cover', + }, + color: { + background: 'var:preset|color|base', + text: 'var:preset|color|contrast', + }, + }, + } ); + + expect( result.style?.background ).toEqual( { + backgroundImage: 'none', + } ); + expect( result.style?.color?.background ).toBeUndefined(); + expect( result.style?.color?.text ).toBe( + 'var:preset|color|contrast' + ); + } ); + } ); + + describe( 'backgroundSelectedStateResetAllFilter', () => { + it( 'only unsets the selected viewport when given a full style object', () => { + const result = backgroundSelectedStateResetAllFilter( + { + style: { + background: { + backgroundImage: { url: 'desktop.png' }, + }, + '@mobile': { + background: { + backgroundImage: { url: 'mobile.png' }, + }, + }, + }, + }, + { viewport: '@mobile', pseudo: 'default' } + ); + + expect( result.style?.background?.backgroundImage ).toEqual( { + url: 'desktop.png', + } ); + expect( result.style?.[ '@mobile' ]?.background ).toEqual( { + backgroundImage: 'none', + } ); + } ); + } ); + describe( 'backgroundResetAllFilter', () => { it( 'clears every background-owned value, including a custom legacy color.gradient', () => { const result = backgroundResetAllFilter( { From 5562546971bd9f0370aec983e215d4a3d771bbc9 Mon Sep 17 00:00:00 2001 From: Shail Mehta Date: Tue, 11 Aug 2026 22:23:37 +0530 Subject: [PATCH 2/3] Added Changelog Entry --- packages/block-editor/CHANGELOG.md | 2 +- .../src/components/background-image-control/index.js | 5 ++--- packages/block-editor/src/hooks/background.js | 5 +---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 92b6768283969f..5c5c30966e1373 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -36,7 +36,7 @@ - Background block support: Fix gradients not being applied to a block when a theme opts out of `settings.background.gradient` in `theme.json` ([#81056](https://github.com/WordPress/gutenberg/pull/81056)). - `LinkControl`: Restore the preview title underline by slightly increasing the title's line height, which was too tight for the underline to be visible ([#81083](https://github.com/WordPress/gutenberg/pull/81083)). - `URLInput`: Skip link search requests while an IME composition is in progress; the search now fires once with the confirmed value on `compositionend` ([#80602](https://github.com/WordPress/gutenberg/pull/80602)). -- Style states: Allow unsetting a background image in a viewport or pseudo state by persisting `background-image: none` and exposing the default-state image as inherited ([#80388](https://github.com/WordPress/gutenberg/issues/80388)). +- Style states: Allow unsetting a background image in a viewport or pseudo state by persisting `background-image: none` and exposing the default-state image as inherited ([#81448](https://github.com/WordPress/gutenberg/pull/81448)). ## 16.1.0 (2026-07-29) diff --git a/packages/block-editor/src/components/background-image-control/index.js b/packages/block-editor/src/components/background-image-control/index.js index 4d73d0dbc64bb3..08e71ee31f71f6 100644 --- a/packages/block-editor/src/components/background-image-control/index.js +++ b/packages/block-editor/src/components/background-image-control/index.js @@ -741,9 +741,8 @@ export default function BackgroundImagePanel( { hasBackgroundImageValue( resolvedInheritedValue ); const imageValue = hasExplicitImageUnset - ? 'none' - : localBackgroundImage || - inheritedValue?.background?.backgroundImage; + ? 'none' + : localBackgroundImage || inheritedValue?.background?.backgroundImage; const shouldShowBackgroundImageControls = hasImageValue && diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 3293309accb4f3..ac2c4a1de69258 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -233,10 +233,7 @@ function BackgroundInspectorControl( { children } ) { ); return ( - + { children } ); From e30e1aadc0c98764ffbc557df6ce07cd125074f4 Mon Sep 17 00:00:00 2001 From: Shail Mehta Date: Tue, 11 Aug 2026 22:35:47 +0530 Subject: [PATCH 3/3] Fix Lint --- .../src/components/background-image-control/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-editor/src/components/background-image-control/index.js b/packages/block-editor/src/components/background-image-control/index.js index 08e71ee31f71f6..f9dde0c0da102d 100644 --- a/packages/block-editor/src/components/background-image-control/index.js +++ b/packages/block-editor/src/components/background-image-control/index.js @@ -741,8 +741,8 @@ export default function BackgroundImagePanel( { hasBackgroundImageValue( resolvedInheritedValue ); const imageValue = hasExplicitImageUnset - ? 'none' - : localBackgroundImage || inheritedValue?.background?.backgroundImage; + ? 'none' + : localBackgroundImage || inheritedValue?.background?.backgroundImage; const shouldShowBackgroundImageControls = hasImageValue &&