From 99844d54ff06bf64ccbdcb24831ea2a9af6eb01e Mon Sep 17 00:00:00 2001 From: Arukuen Date: Mon, 28 Sep 2026 11:18:51 +0800 Subject: [PATCH 1/2] fix: add core's responsive style compatibility and filtering of control and panels --- src/block-components/block-link/edit.js | 1 + src/block-components/block-style/edit.js | 1 + .../conditional-display/edit.js | 1 + .../custom-attributes/edit.js | 1 + src/block-components/custom-css/edit.js | 1 + .../effects-animations/edit.js | 1 + .../helpers/backgrounds/edit.js | 1 + src/block-components/link/edit.js | 1 + src/block-components/transform/edit.js | 1 + src/block-components/typography/edit.js | 1 + .../advanced-toggle-control/index.js | 1 + src/components/base-control/index.js | 11 ++ src/components/base-control2/index.js | 10 ++ .../button-icon-popover-control/index.js | 2 + src/components/inspector-tabs/index.js | 46 ++++++-- src/components/inspector-tabs/readme.md | 73 ++++++++++++ ...se-core-responsive-styles-compatibility.js | 108 ++++++++++++++++++ .../panel-advanced-settings/panel-body.js | 56 ++++++--- .../panel-advanced-settings/readme.md | 15 +++ src/components/panel-tabs/editor.scss | 9 ++ .../__test__/index.test.js | 50 ++++++++ .../responsive-control-visibility/index.js | 79 +++++++++++++ .../use-responsive-control-visibility.test.js | 85 ++++++++++++++ src/hooks/use-core-responsive-editing.js | 103 +++++++++++++++++ .../use-responsive-control-visibility.js | 85 ++++++++++++++ 25 files changed, 715 insertions(+), 28 deletions(-) create mode 100644 src/components/inspector-tabs/readme.md create mode 100644 src/components/inspector-tabs/use-core-responsive-styles-compatibility.js create mode 100644 src/components/responsive-control-visibility/__test__/index.test.js create mode 100644 src/components/responsive-control-visibility/index.js create mode 100644 src/hooks/__test__/use-responsive-control-visibility.test.js create mode 100644 src/hooks/use-core-responsive-editing.js create mode 100644 src/hooks/use-responsive-control-visibility.js diff --git a/src/block-components/block-link/edit.js b/src/block-components/block-link/edit.js index c8a2232384..b5493e5529 100644 --- a/src/block-components/block-link/edit.js +++ b/src/block-components/block-link/edit.js @@ -26,6 +26,7 @@ export const Edit = props => { { title={ __( 'Styles', i18n ) } id="styles" initialOpen={ props.initialOpen } + responsive={ false } > { props.children } diff --git a/src/block-components/conditional-display/edit.js b/src/block-components/conditional-display/edit.js index dcd80402d8..adc33f81d9 100644 --- a/src/block-components/conditional-display/edit.js +++ b/src/block-components/conditional-display/edit.js @@ -29,6 +29,7 @@ export const Edit = () => { title={ __( 'Conditional Display', i18n ) } id="conditional-display" isPremiumPanel={ ! isPro } + responsive={ false } > { ! isPro && } { isPro && diff --git a/src/block-components/custom-attributes/edit.js b/src/block-components/custom-attributes/edit.js index 4f54187a1f..8a0c01a524 100644 --- a/src/block-components/custom-attributes/edit.js +++ b/src/block-components/custom-attributes/edit.js @@ -28,6 +28,7 @@ export const Edit = () => { { title={ __( 'Custom CSS', i18n ) } id="custom-css" isPremiumPanel={ ! isPro } + responsive={ false } showModifiedIndicator={ !! customCSSMinified } > { ! isPro && } diff --git a/src/block-components/effects-animations/edit.js b/src/block-components/effects-animations/edit.js index 0043a7bcf6..373e702df8 100644 --- a/src/block-components/effects-animations/edit.js +++ b/src/block-components/effects-animations/edit.js @@ -29,6 +29,7 @@ export const Edit = props => { title={ __( 'Motion Effects', i18n ) } id="effects-animations" isPremiumPanel={ ! isPro } + responsive={ false } > { ! isPro && } { isPro && diff --git a/src/block-components/helpers/backgrounds/edit.js b/src/block-components/helpers/backgrounds/edit.js index 20bf26f31b..62780450c1 100644 --- a/src/block-components/helpers/backgrounds/edit.js +++ b/src/block-components/helpers/backgrounds/edit.js @@ -247,6 +247,7 @@ export const BackgroundControls = props => { { hasBackgroundMedia && { updateAttributes( { BackgroundPosition: '', diff --git a/src/block-components/link/edit.js b/src/block-components/link/edit.js index ec9b824ba7..2999e6a220 100644 --- a/src/block-components/link/edit.js +++ b/src/block-components/link/edit.js @@ -35,6 +35,7 @@ export const Edit = props => { { title={ __( 'Transform & Transition', i18n ) } id="transform-transition" isPremiumPanel={ ! isPro } + responsive={ false } > { ! isPro && } { isPro && diff --git a/src/block-components/typography/edit.js b/src/block-components/typography/edit.js index 15b46f4f03..0a8049faff 100644 --- a/src/block-components/typography/edit.js +++ b/src/block-components/typography/edit.js @@ -142,6 +142,7 @@ export const Controls = props => { { updateAttributes( { [ getAttributeName( 'fontFamily' ) ]: '', diff --git a/src/components/advanced-toggle-control/index.js b/src/components/advanced-toggle-control/index.js index ba72b0b393..7ea26bae50 100644 --- a/src/components/advanced-toggle-control/index.js +++ b/src/components/advanced-toggle-control/index.js @@ -54,6 +54,7 @@ const AdvancedToggleControl = memo( props => { value={ checked } showReset={ props.defaultValue ? checked !== props.defaultValue : checked } onChange={ onChange } + screens={ props.responsive } hasLabel={ false } defaultValue={ props.defaultValue } > diff --git a/src/components/base-control/index.js b/src/components/base-control/index.js index c0866f1821..f2fcae93f2 100644 --- a/src/components/base-control/index.js +++ b/src/components/base-control/index.js @@ -7,6 +7,8 @@ */ import BaseControlMultiLabel from '../base-control-multi-label' import Button from '../button' +import { useRegisterResponsivePanelControl } from '../responsive-control-visibility' +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' /** * External dependencies @@ -21,6 +23,11 @@ import { i18n } from 'stackable' import { __ } from '@wordpress/i18n' const BaseControl = props => { + const isVisible = useResponsiveControlVisibility( props.screens ) + // Register even when this control returns null so its parent panel can tell + // when responsive filtering has removed every control inside it. + useRegisterResponsivePanelControl( isVisible ) + const className = classnames( [ 'stk-inspector-control', props.className, @@ -33,6 +40,10 @@ const BaseControl = props => { ? props.showReset : ( typeof props.value !== 'undefined' && props.value !== props.defaultValue && props.value !== props.placeholder ) + if ( ! isVisible ) { + return null + } + return ( <_BaseControl help={ props.help } diff --git a/src/components/base-control2/index.js b/src/components/base-control2/index.js index 9e1c2ba9e0..11c44650e5 100644 --- a/src/components/base-control2/index.js +++ b/src/components/base-control2/index.js @@ -10,9 +10,11 @@ import ResponsiveToggle from '../responsive-toggle' import HoverStateToggle from './hover-state-toggle' import { VisualGuideer } from './use-visual-guide' import LabelTooltip from './label-tooltip' +import { useRegisterResponsivePanelControl } from '../responsive-control-visibility' import { useAttributeName, useBlockAttributesContext, useBlockSetAttributesContext, useDeviceType, } from '~stackable/hooks' +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' /** * External dependencies @@ -36,6 +38,10 @@ const EMPTY_OBJ = {} export const BaseControl = props => { const deviceType = useDeviceType() + const isVisible = useResponsiveControlVisibility( props.responsive ) + // Register even when this control returns null so its parent panel can tell + // when responsive filtering has removed every control inside it. + useRegisterResponsivePanelControl( isVisible ) const className = classnames( [ 'stk-control', @@ -64,6 +70,10 @@ export const BaseControl = props => { const VisualGuide = props.visualGuide !== EMPTY_OBJ ? VisualGuideer : Fragment + if ( ! isVisible ) { + return null + } + return ( { className={ classnames( 'ugb-button-icon-control', props.className ) } allowReset={ true } showReset={ props.allowReset || ( props.onToggle ? props.checked : false ) } + screens={ props.screens } onReset={ () => { props.onReset() if ( props.onToggle ) { @@ -109,6 +110,7 @@ ButtonIconPopoverControl.defaultProps = { onReset: () => {}, checked: false, onToggle: undefined, + screens: [ 'desktop' ], } export default ButtonIconPopoverControl diff --git a/src/components/inspector-tabs/index.js b/src/components/inspector-tabs/index.js index 74e8e7b3e5..7e56f6e01d 100644 --- a/src/components/inspector-tabs/index.js +++ b/src/components/inspector-tabs/index.js @@ -16,13 +16,18 @@ import { useGlobalState } from '~stackable/util/global-state' import { __ } from '@wordpress/i18n' import { getBlockSupport } from '@wordpress/blocks' import { BlockStylesControl } from '../block-styles-control' +import ResponsiveControlVisibility, { ResponsiveControlFilterProvider } from '../responsive-control-visibility' +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' +import useCoreResponsiveStylesCompatibility from './use-core-responsive-styles-compatibility' const { Slot: LayoutPanelSlot, Fill: LayoutPanelFill } = createSlotFill( 'StackableLayoutPanel' ) const InspectorLayoutControls = ( { children } ) => { - return - { children } - + return + + { children } + + } const InspectorBlockControls = ( { children } ) => { @@ -33,7 +38,9 @@ const InspectorBlockControls = ( { children } ) => { return null } - return { children } + return + { children } + } const InspectorStyleControls = ( { children } ) => { @@ -44,7 +51,9 @@ const InspectorStyleControls = ( { children } ) => { return null } - return { children } + return + { children } + } const InspectorAdvancedControls = ( { children } ) => { @@ -55,7 +64,9 @@ const InspectorAdvancedControls = ( { children } ) => { return null } - return { children } + return + { children } + } export { @@ -65,16 +76,31 @@ export { InspectorAdvancedControls, } +const ResponsivePanelTabs = props => { + // Core owns its Advanced panel, so expose a scoped marker that lets the + // stylesheet mirror Core's responsive inspector without hiding our tab. + const isResponsiveFiltering = ! useResponsiveControlVisibility( false ) + + return +} + const InspectorTabs = props => { const { name, clientId } = useBlockEditContext() const defaultTab = getBlockSupport( name, 'stkDefaultTab' ) || 'style' const [ activeTab, setActiveTab ] = useGlobalState( `tabCache-${ name }`, props.tabs.includes( defaultTab ) ? defaultTab : 'style' ) + useCoreResponsiveStylesCompatibility( clientId ) + return ( - <> + - { ( isPro || showProNotice ) && } - + { ( isPro || showProNotice ) && } + + { ) } - + ) } diff --git a/src/components/inspector-tabs/readme.md b/src/components/inspector-tabs/readme.md new file mode 100644 index 0000000000..9a101ff624 --- /dev/null +++ b/src/components/inspector-tabs/readme.md @@ -0,0 +1,73 @@ +# Responsive Styles compatibility + +This note describes how the Stackable inspector works with the Responsive Styles feature introduced in WordPress 7.1. +It documents the current implementation and does not define a separate responsive attribute system. + +## Why the bridge exists + +WordPress represents its responsive editing selection as a private block style state such as `@tablet` or `@mobile`. +When one of those states is selected, Core replaces the normal block inspector with its style-state inspector. +That inspector renders controls registered with Core style states, but it does not render Stackable's custom inspector controls. + +Stackable already selects its desktop, tablet, and mobile attributes from the editor's visual device type. +The compatibility bridge therefore resets only Core's style-state viewport to `default` while a Stackable block is selected. +It does not change the visual device preview. +Stackable continues to read and write its existing viewport-specific attributes. + +When selection leaves the Stackable block, the bridge restores Core's `@tablet` or `@mobile` style-state viewport so native blocks retain their normal behavior. + +## Runtime flow + +1. [`use-core-responsive-editing.js`](../../hooks/use-core-responsive-editing.js) safely unlocks the private block editor selectors and dispatchers. +2. [`use-core-responsive-styles-compatibility.js`](./use-core-responsive-styles-compatibility.js) keeps the normal Stackable inspector mounted and restores Core state when Stackable no longer owns the selection. +3. [`use-responsive-control-visibility.js`](../../hooks/use-responsive-control-visibility.js) combines Core's Responsive Styles toggle with Stackable's visual device type. +4. [`index.js`](./index.js) scopes that filtering state to Stackable inspector controls. +5. [`base-control/index.js`](../base-control/index.js) and [`base-control2/index.js`](../base-control2/index.js) hide controls that do not support the current viewport. +6. [`responsive-control-visibility/index.js`](../responsive-control-visibility/index.js) lets controls report their visibility to their parent panel. +7. [`panel-advanced-settings/panel-body.js`](../panel-advanced-settings/panel-body.js) hides a panel when all registered controls are filtered, or when the panel has an explicit unsupported capability. +8. [`panel-tabs/editor.scss`](../panel-tabs/editor.scss) hides Core's own Advanced panel while Stackable responsive filtering is active. + +## Visibility rules + +Filtering is inactive when Responsive Styles is disabled or the visual device is Desktop. +All Stackable controls and panels remain visible in those cases. + +Filtering is active when Responsive Styles is enabled and the visual device is Tablet or Mobile. +During filtering, a control is visible only when its existing `screens` or `responsive` metadata includes the current device. + +Use `responsive="all"` or `screens="all"` for controls that support Desktop, Tablet, and Mobile. +Use an array when a control supports only specific devices. +An omitted or false control capability is treated as non-responsive during filtering. + +The metadata affects inspector visibility only. +It does not select attributes, change values, or alter Stackable's existing responsive write behavior. + +## Panel behavior + +Panels containing `BaseControl` or `BaseControl2` children normally do not need a `responsive` prop. +Their child controls register their visibility, and the panel hides itself when every registered child is filtered. + +Panels with custom, filtered, or third-party content may not have children that participate in registration. +Known desktop-only panels must use `responsive={ false }` so their capability is explicit. +An unannotated panel with no registered controls remains visible because its capability is unknown. +This fallback avoids accidentally hiding a custom panel that may support responsive editing. + +## Core Advanced panel + +WordPress owns the built-in Advanced panel that contains controls such as HTML anchor and Additional CSS classes. +It is not a Stackable `PanelAdvancedSettings`, so Stackable cannot pass `responsive={ false }` to it. + +`ResponsivePanelTabs` adds the `ugb-panel-tabs--is-responsive-filtering` marker while responsive filtering is active. +The panel-tabs stylesheet uses that marker to hide Core's `.block-editor-block-inspector__advanced` panel. +Stackable's own Advanced tab remains available. + +## Private API boundary + +WordPress 7.1 does not expose the required Responsive Styles state through a public API. +The bridge uses `window.wp.privateApis.__dangerousOptInToUnstableAPIsOnlyForCoreModules` to unlock the block editor store. +All private API access is isolated and guarded with optional access and `try` blocks. + +If the private API is unavailable, the helpers return `undefined` and responsive filtering defaults to inactive. +This prevents an unsupported WordPress version or a future private API change from causing a JavaScript error. + +When WordPress exposes a stable public API, replace the private access inside `use-core-responsive-editing.js` while preserving the rest of the visibility interface. diff --git a/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js b/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js new file mode 100644 index 0000000000..f549081ade --- /dev/null +++ b/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data' +import { useEffect, useRef } from '@wordpress/element' +import { useDeviceType } from '~stackable/hooks/use-device-type' +import { + DEFAULT_STYLE_STATE_VIEWPORT, + getPrivateBlockEditorDispatch, + getPrivateBlockEditorSelectors, + getStyleStateViewportForDeviceType, +} from '~stackable/hooks/use-core-responsive-editing' + +/** + * Stackable already stores responsive values in its own tablet and mobile + * attributes. WordPress 7.1 responsive editing hides the normal inspector and + * only renders controls backed by Core style states. Temporarily opt the + * selected Stackable block out of that Core viewport state while preserving + * the editor's visual device preview. + * + * @param {string} clientId Block client ID. + */ +const useCoreResponsiveStylesCompatibility = clientId => { + // Remember the viewport that Core owned before Stackable temporarily moved + // the style-state inspector back to its default state. + const previousViewport = useRef( DEFAULT_STYLE_STATE_VIEWPORT ) + const compatibilityState = useRef() + const deviceType = useDeviceType() + const dispatchers = useDispatch( 'core/block-editor' ) + const privateDispatchers = getPrivateBlockEditorDispatch( dispatchers ) + const setStyleStateViewport = privateDispatchers?.setStyleStateViewport + + const { + isSelected, + isResponsiveEditing, + styleStateViewport, + } = useSelect( select => { + const blockEditor = select( 'core/block-editor' ) + const privateSelectors = getPrivateBlockEditorSelectors( select ) + + return { + isSelected: blockEditor.isBlockSelected( clientId ), + isResponsiveEditing: privateSelectors?.isResponsiveEditing?.() || false, + styleStateViewport: privateSelectors?.getStyleStateViewport?.() || DEFAULT_STYLE_STATE_VIEWPORT, + } + }, [ clientId ] ) + compatibilityState.current = { + deviceType, + isResponsiveEditing, + styleStateViewport, + } + + useEffect( () => { + if ( ! setStyleStateViewport ) { + return + } + + if ( isSelected && isResponsiveEditing ) { + if ( deviceType === 'Desktop' ) { + previousViewport.current = DEFAULT_STYLE_STATE_VIEWPORT + } else if ( styleStateViewport !== DEFAULT_STYLE_STATE_VIEWPORT ) { + previousViewport.current = styleStateViewport + // Change only Core's inspector state. The visual Tablet or Mobile + // preview still drives Stackable's responsive attributes. + setStyleStateViewport( DEFAULT_STYLE_STATE_VIEWPORT ) + } + + return + } + + if ( ! isSelected && previousViewport.current !== DEFAULT_STYLE_STATE_VIEWPORT ) { + if ( isResponsiveEditing && styleStateViewport === DEFAULT_STYLE_STATE_VIEWPORT && deviceType !== 'Desktop' ) { + // Native blocks need their Core viewport state restored so their + // normal Responsive Styles inspector can take over again. + setStyleStateViewport( getStyleStateViewportForDeviceType( deviceType ) ) + } + + previousViewport.current = DEFAULT_STYLE_STATE_VIEWPORT + } + }, [ + deviceType, + isResponsiveEditing, + isSelected, + setStyleStateViewport, + styleStateViewport, + ] ) + + useEffect( () => { + return () => { + // Selection changes normally restore the viewport above. This cleanup + // also covers block deletion and inspector unmounting. + const state = compatibilityState.current + if ( + setStyleStateViewport && + previousViewport.current !== DEFAULT_STYLE_STATE_VIEWPORT && + state.isResponsiveEditing && + state.styleStateViewport === DEFAULT_STYLE_STATE_VIEWPORT && + state.deviceType !== 'Desktop' + ) { + setStyleStateViewport( getStyleStateViewportForDeviceType( state.deviceType ) ) + } + + previousViewport.current = DEFAULT_STYLE_STATE_VIEWPORT + } + }, [ setStyleStateViewport ] ) +} + +export default useCoreResponsiveStylesCompatibility diff --git a/src/components/panel-advanced-settings/panel-body.js b/src/components/panel-advanced-settings/panel-body.js index a7d0e28e76..3e87da7657 100644 --- a/src/components/panel-advanced-settings/panel-body.js +++ b/src/components/panel-advanced-settings/panel-body.js @@ -7,6 +7,11 @@ import classnames from 'classnames' * Internal dependencies */ import { useGlobalState } from '~stackable/util/global-state' +import { + ResponsivePanelControlProvider, + useResponsivePanelControls, +} from '../responsive-control-visibility' +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' /** * WordPress dependencies @@ -56,11 +61,19 @@ const PanelBody = ( onChange = noop, isPremiumPanel = false, showModifiedIndicator = false, + responsive, }, ref ) => { const { name } = useBlockEditContext() const [ _isOpened, setIsOpened ] = useGlobalState( `panelCache-${ name }-${ id }-${ title }`, initialOpen === undefined ? false : initialOpen ) + const { registerControl, shouldHide } = useResponsivePanelControls() + // Explicit panel metadata takes priority. Otherwise infer visibility from + // registered BaseControl children and keep unknown custom panels visible. + const hasExplicitResponsiveCapability = typeof responsive !== 'undefined' + const isExplicitlyVisible = useResponsiveControlVisibility( hasExplicitResponsiveCapability ? responsive : 'all' ) + const isPanelToggleVisible = useResponsiveControlVisibility( false ) + const isHidden = hasExplicitResponsiveCapability ? ! isExplicitlyVisible : shouldHide const isOpened = isForcedOpen === null ? _isOpened : isForcedOpen @@ -93,24 +106,31 @@ const PanelBody = ( } ) return ( -
- - { typeof children === 'function' - ? children( { opened: true } ) - : children } -
+ + { /* Keep the panel mounted so child visibility registrations remain stable. */ } + + ) } diff --git a/src/components/panel-advanced-settings/readme.md b/src/components/panel-advanced-settings/readme.md index 3001e82b14..a3825ad7d3 100644 --- a/src/components/panel-advanced-settings/readme.md +++ b/src/components/panel-advanced-settings/readme.md @@ -20,3 +20,18 @@ With those 3 props above, the panel will watch for changes in the current block' If any of those gets assigned a value other than blank (empty string), the `showAttr` atttribute will be set to `true`. *This preserves the undo/redo functionality to just 1 step.* + +# Responsive Visibility + +Inside a `ResponsiveControlFilterProvider`, the panel can hide itself when Core Responsive Styles is filtering controls for Tablet or Mobile. + +Panels made from `BaseControl` or `BaseControl2` children normally do not need a `responsive` prop. +The children register their visibility, and the panel hides when every registered control is filtered. + +Use `responsive={ false }` when the whole panel is desktop-only and its custom children cannot register their own responsive capability. +Use `responsive="all"` when a custom panel explicitly supports every viewport. + +An omitted capability on a panel with no registered controls is treated as unknown, so the panel remains visible. +This avoids hiding custom or third-party panel content by accident. + +See the [Responsive Styles compatibility note](../inspector-tabs/readme.md) for the complete inspector flow. diff --git a/src/components/panel-tabs/editor.scss b/src/components/panel-tabs/editor.scss index ffaa35acc2..3ce0f32f59 100644 --- a/src/components/panel-tabs/editor.scss +++ b/src/components/panel-tabs/editor.scss @@ -101,3 +101,12 @@ $tabs: "block", "layout", "style", "advanced"; } } } + +// Core omits its Advanced panel while editing responsive style states. The +// compatibility bridge keeps Stackable on Core's default inspector state, so +// mirror that behavior while Stackable's responsive filtering is active. +.block-editor-block-inspector:has(.ugb-panel-tabs--is-responsive-filtering) { + .block-editor-block-inspector__advanced { + display: none; + } +} diff --git a/src/components/responsive-control-visibility/__test__/index.test.js b/src/components/responsive-control-visibility/__test__/index.test.js new file mode 100644 index 0000000000..c1f6e32a81 --- /dev/null +++ b/src/components/responsive-control-visibility/__test__/index.test.js @@ -0,0 +1,50 @@ +import ResponsiveControlVisibility, { + ResponsivePanelControlProvider, + useResponsivePanelControls, +} from '../' +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' +import { render } from '@testing-library/react' + +jest.mock( '~stackable/hooks/use-responsive-control-visibility' ) + +const Panel = ( { children } ) => { + const { registerControl, shouldHide } = useResponsivePanelControls() + + return ( + + + + ) +} + +describe( 'ResponsiveControlVisibility', () => { + it( 'renders supported controls and keeps their panel visible', () => { + useResponsiveControlVisibility.mockReturnValue( true ) + const { getByTestId, getByText } = render( + + + Responsive control + + + ) + + expect( getByText( 'Responsive control' ) ).toBeTruthy() + expect( getByTestId( 'panel' ).hidden ).toBe( false ) + } ) + + it( 'filters unsupported controls and hides an empty panel', () => { + useResponsiveControlVisibility.mockReturnValue( false ) + const { getByTestId, queryByText } = render( + + + Desktop control + + + ) + + expect( queryByText( 'Desktop control' ) ).toBeNull() + expect( getByTestId( 'panel' ).hidden ).toBe( true ) + } ) +} ) diff --git a/src/components/responsive-control-visibility/index.js b/src/components/responsive-control-visibility/index.js new file mode 100644 index 0000000000..96e8126818 --- /dev/null +++ b/src/components/responsive-control-visibility/index.js @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import useResponsiveControlVisibility from '~stackable/hooks/use-responsive-control-visibility' +export { ResponsiveControlFilterProvider } from '~stackable/hooks/use-responsive-control-visibility' + +/** + * WordPress dependencies + */ +import { + createContext, useCallback, useContext, useLayoutEffect, useRef, useState, +} from '@wordpress/element' + +const ResponsivePanelControlContext = createContext() + +/** + * Track child control visibility so a panel can hide when filtering removes + * all of its registered controls. No registrations means the panel capability + * is unknown, so the panel remains visible unless it has an explicit value. + * + * @return {Object} Registration callback and aggregate visibility state. + */ +export const useResponsivePanelControls = () => { + const [ controls, setControls ] = useState( new Map() ) + + const registerControl = useCallback( ( id, isVisible ) => { + setControls( current => { + if ( current.get( id ) === isVisible ) { + return current + } + + const next = new Map( current ) + next.set( id, isVisible ) + return next + } ) + + return () => { + setControls( current => { + if ( ! current.has( id ) ) { + return current + } + + const next = new Map( current ) + next.delete( id ) + return next + } ) + } + }, [] ) + + const registeredControls = [ ...controls.values() ] + const shouldHide = registeredControls.length > 0 && ! registeredControls.some( Boolean ) + + return { + registerControl, + shouldHide, + } +} + +export const useRegisterResponsivePanelControl = isVisible => { + const registerControl = useContext( ResponsivePanelControlContext ) + const controlId = useRef( {} ) + + // Register before paint so an empty panel does not visibly flash while its + // controls report their responsive capabilities. + useLayoutEffect( () => { + return registerControl?.( controlId.current, isVisible ) + }, [ isVisible, registerControl ] ) +} + +export const ResponsivePanelControlProvider = ResponsivePanelControlContext.Provider + +const ResponsiveControlVisibility = ( { children, responsive } ) => { + const isVisible = useResponsiveControlVisibility( responsive ) + useRegisterResponsivePanelControl( isVisible ) + + return isVisible ? children : null +} + +export default ResponsiveControlVisibility diff --git a/src/hooks/__test__/use-responsive-control-visibility.test.js b/src/hooks/__test__/use-responsive-control-visibility.test.js new file mode 100644 index 0000000000..a25cf281b2 --- /dev/null +++ b/src/hooks/__test__/use-responsive-control-visibility.test.js @@ -0,0 +1,85 @@ +import useResponsiveControlVisibility, { + isResponsiveControlVisible, + normalizeResponsiveScreens, + ResponsiveControlFilterProvider, +} from '../use-responsive-control-visibility' +import useCoreResponsiveEditing from '../use-core-responsive-editing' +import { useDeviceType } from '../use-device-type' +import { render } from '@testing-library/react' + +jest.mock( '../use-core-responsive-editing' ) +jest.mock( '../use-device-type' ) + +const Visibility = ( { responsive } ) => { + return useResponsiveControlVisibility( responsive ) ? 'visible' : 'hidden' +} + +describe( 'responsive control visibility', () => { + it( 'shows every control when responsive editing is disabled', () => { + expect( isResponsiveControlVisible( { + deviceType: 'Mobile', + isResponsiveEditing: false, + responsive: false, + } ) ).toBe( true ) + } ) + + it( 'shows every control on desktop', () => { + expect( isResponsiveControlVisible( { + deviceType: 'Desktop', + isResponsiveEditing: true, + responsive: false, + } ) ).toBe( true ) + } ) + + it.each( [ + [ 'Tablet', 'all', true ], + [ 'Tablet', [ 'desktop', 'tablet' ], true ], + [ 'Tablet', [ 'desktop', 'mobile' ], false ], + [ 'Tablet', false, false ], + [ 'Mobile', 'all', true ], + [ 'Mobile', [ 'desktop', 'mobile' ], true ], + [ 'Mobile', [ 'desktop', 'tablet' ], false ], + [ 'Mobile', false, false ], + ] )( 'filters %s controls using %p capability', ( deviceType, responsive, expected ) => { + expect( isResponsiveControlVisible( { + deviceType, + isResponsiveEditing: true, + responsive, + } ) ).toBe( expected ) + } ) + + it( 'shows controls when the editor does not expose a device type', () => { + expect( isResponsiveControlVisible( { + deviceType: undefined, + isResponsiveEditing: true, + responsive: false, + } ) ).toBe( true ) + } ) + + it( 'normalizes the all shortcut and rejects unsupported capability values', () => { + expect( normalizeResponsiveScreens( 'all' ) ).toEqual( [ 'desktop', 'tablet', 'mobile' ] ) + expect( normalizeResponsiveScreens( [ 'tablet' ] ) ).toEqual( [ 'tablet' ] ) + expect( normalizeResponsiveScreens( false ) ).toEqual( [] ) + } ) + + it( 'limits filtering to controls inside a responsive filter provider', () => { + useCoreResponsiveEditing.mockReturnValue( true ) + useDeviceType.mockReturnValue( 'Tablet' ) + + const { getByTestId } = render( + <> +
+ +
+ +
+ +
+
+ + ) + + expect( getByTestId( 'outside' ).textContent ).toBe( 'visible' ) + expect( getByTestId( 'inside' ).textContent ).toBe( 'hidden' ) + } ) +} ) diff --git a/src/hooks/use-core-responsive-editing.js b/src/hooks/use-core-responsive-editing.js new file mode 100644 index 0000000000..c3d52091ac --- /dev/null +++ b/src/hooks/use-core-responsive-editing.js @@ -0,0 +1,103 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data' + +export const DEFAULT_STYLE_STATE_VIEWPORT = 'default' + +const PRIVATE_APIS_CONSENT = 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.' + +let unlockPrivateApis + +/** + * Core does not publicly expose Responsive Styles state in WordPress 7.1. + * Keep the unstable opt-in isolated here so callers have a guarded boundary + * that can be replaced if Core provides a public API. + * + * @return {Function|undefined} Core's private API unlock function. + */ +const getUnlockPrivateApis = () => { + if ( unlockPrivateApis ) { + return unlockPrivateApis + } + + if ( typeof window === 'undefined' ) { + return undefined + } + + try { + unlockPrivateApis = window.wp?.privateApis + ?.__dangerousOptInToUnstableAPIsOnlyForCoreModules( + PRIVATE_APIS_CONSENT, + '@wordpress/block-editor' + )?.unlock + } catch { + unlockPrivateApis = undefined + } + + return unlockPrivateApis +} + +/** + * Unlock private selectors on the Core block editor store. + * + * @param {Function} select WordPress data select function. + * @return {Object|undefined} Unlocked selectors when the API is available. + */ +export const getPrivateBlockEditorSelectors = select => { + const unlock = getUnlockPrivateApis() + + try { + return unlock ? unlock( select( 'core/block-editor' ) ) : undefined + } catch { + return undefined + } +} + +/** + * Unlock private actions on the Core block editor store. + * + * @param {Object} dispatchers Public block editor dispatchers. + * @return {Object|undefined} Unlocked dispatchers when the API is available. + */ +export const getPrivateBlockEditorDispatch = dispatchers => { + const unlock = getUnlockPrivateApis() + + try { + return unlock ? unlock( dispatchers ) : undefined + } catch { + return undefined + } +} + +/** + * Convert Stackable's visual device name to Core's style-state viewport. + * + * @param {string} deviceType Stackable device name. + * @return {string} Core style-state viewport. + */ +export const getStyleStateViewportForDeviceType = deviceType => { + if ( deviceType === 'Tablet' ) { + return '@tablet' + } + + if ( deviceType === 'Mobile' ) { + return '@mobile' + } + + return DEFAULT_STYLE_STATE_VIEWPORT +} + +/** + * Read whether Core's Responsive Styles toggle is enabled. + * + * @return {boolean} Whether Responsive Styles is enabled. + */ +const useCoreResponsiveEditing = () => { + return useSelect( select => { + const privateSelectors = getPrivateBlockEditorSelectors( select ) + return privateSelectors?.isResponsiveEditing?.() || false + }, [] ) +} + +export default useCoreResponsiveEditing diff --git a/src/hooks/use-responsive-control-visibility.js b/src/hooks/use-responsive-control-visibility.js new file mode 100644 index 0000000000..312f75d0da --- /dev/null +++ b/src/hooks/use-responsive-control-visibility.js @@ -0,0 +1,85 @@ +/** + * Internal dependencies + */ +import { useDeviceType } from './use-device-type' +import useCoreResponsiveEditing from './use-core-responsive-editing' + +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element' + +const ALL_SCREENS = [ 'desktop', 'tablet', 'mobile' ] +const ResponsiveControlFilteringContext = createContext( { + deviceType: undefined, + isResponsiveEditing: false, +} ) + +/** + * Normalize the responsive metadata already used by Stackable controls. + * This visibility metadata does not change which responsive attribute a + * control reads or writes. + * + * @param {string|string[]|boolean} responsive Responsive control metadata. + * @return {string[]} Supported device names. + */ +export const normalizeResponsiveScreens = responsive => { + if ( responsive === 'all' ) { + return ALL_SCREENS + } + + return Array.isArray( responsive ) ? responsive : [] +} + +/** + * Determine control visibility without changing which attribute the control + * reads or writes. + * + * @param {Object} options Visibility inputs. + * @param {string} options.deviceType Current visual device. + * @param {boolean} options.isResponsiveEditing Core toggle state. + * @param {string|string[]|boolean} options.responsive Supported devices. + * @return {boolean} Whether the control should be rendered. + */ +export const isResponsiveControlVisible = ( { + deviceType, + isResponsiveEditing, + responsive, +} ) => { + if ( ! isResponsiveEditing || ! deviceType || deviceType === 'Desktop' ) { + return true + } + + return normalizeResponsiveScreens( responsive ).includes( deviceType.toLowerCase() ) +} + +/** + * Scope Core responsive filtering to Stackable inspector trees. + * Controls outside this provider use the non-filtering context default. + * + * @param {Object} options Component options. + * @param {*} options.children Inspector controls. + * @return {*} Context provider. + */ +export const ResponsiveControlFilterProvider = ( { children } ) => { + const deviceType = useDeviceType() + const isResponsiveEditing = useCoreResponsiveEditing() + + return ( + + { children } + + ) +} + +const useResponsiveControlVisibility = responsive => { + const { deviceType, isResponsiveEditing } = useContext( ResponsiveControlFilteringContext ) + + return isResponsiveControlVisible( { + deviceType, + isResponsiveEditing, + responsive, + } ) +} + +export default useResponsiveControlVisibility From 43f975e1394070b17fdd2a91cb506209cb7ec93b Mon Sep 17 00:00:00 2001 From: Arukuen Date: Tue, 29 Sep 2026 16:50:27 +0800 Subject: [PATCH 2/2] fix: update previousViewport regardless of styleStateViewport --- ...use-core-responsive-styles-compatibility.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js b/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js index f549081ade..7cf0a36702 100644 --- a/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js +++ b/src/components/inspector-tabs/use-core-responsive-styles-compatibility.js @@ -58,11 +58,19 @@ const useCoreResponsiveStylesCompatibility = clientId => { if ( isSelected && isResponsiveEditing ) { if ( deviceType === 'Desktop' ) { previousViewport.current = DEFAULT_STYLE_STATE_VIEWPORT - } else if ( styleStateViewport !== DEFAULT_STYLE_STATE_VIEWPORT ) { - previousViewport.current = styleStateViewport - // Change only Core's inspector state. The visual Tablet or Mobile - // preview still drives Stackable's responsive attributes. - setStyleStateViewport( DEFAULT_STYLE_STATE_VIEWPORT ) + } else { + // Core may already be at its default viewport when the visual device + // changes. Remember the active device so native blocks can still have + // their responsive inspector restored when Stackable is deselected. + previousViewport.current = styleStateViewport !== DEFAULT_STYLE_STATE_VIEWPORT + ? styleStateViewport + : getStyleStateViewportForDeviceType( deviceType ) + + if ( styleStateViewport !== DEFAULT_STYLE_STATE_VIEWPORT ) { + // Change only Core's inspector state. The visual Tablet or Mobile + // preview still drives Stackable's responsive attributes. + setStyleStateViewport( DEFAULT_STYLE_STATE_VIEWPORT ) + } } return