Commit 4f98b1b8748 for woocommerce
commit 4f98b1b8748a23c907f708e198718a31389233a6
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Wed May 27 10:49:33 2026 +0200
Move DisplayStyleSwitcher to editor-components (#65239)
* Update legacy context key reference
* Move DisplayStyleSwitcher to editor-components
* Rename filterBlock to parentBlock
* Discover display styles via block support (#65294)
* update: discover display styles via block support
* fix: harden display style discovery
* fix: resolve display style switcher types
* chore: align display style insertion helper name
* docs: explain the need of fallback insertion point
* CodeRabbit suggestion
* Add extra validation
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
diff --git a/plugins/woocommerce/changelog/update-display-style-switch-to-editor-components b/plugins/woocommerce/changelog/update-display-style-switch-to-editor-components
new file mode 100644
index 00000000000..a55ca064858
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-display-style-switch-to-editor-components
@@ -0,0 +1,3 @@
+Significance: patch
+Type: tweak
+Comment: Move DisplayStyleSwitcher to editor-components and discover display styles via block supports
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index ea1225b097e..c9e6caaaf21 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -10,7 +10,7 @@ import {
store as blockEditorStore,
__experimentalUseBlockPreview as useBlockPreview,
} from '@wordpress/block-editor';
-import { BlockInstance, type BlockEditProps } from '@wordpress/blocks';
+import type { BlockEditProps, BlockInstance } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
import {
CustomDataProvider,
@@ -21,6 +21,10 @@ import { isProductResponseItem } from '@woocommerce/entities';
import type { ProductResponseAttributeItem } from '@woocommerce/types';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
+import {
+ DisplayStyleSwitcher,
+ resetDisplayStyleBlock,
+} from '@woocommerce/editor-components/display-style-switcher';
import {
ToggleControl,
__experimentalToggleGroupControl as ToggleGroupControl,
@@ -33,10 +37,6 @@ import {
* Internal dependencies
*/
import { DEFAULT_ATTRIBUTES, EMPTY_TERM_COLORS } from './constants';
-import {
- DisplayStyleSwitcher,
- resetDisplayStyleBlock,
-} from '../../../product-filters/components/display-style-switcher';
import type {
SelectableItem,
SelectableItemsContext,
@@ -44,6 +44,26 @@ import type {
const INNER_CHIPS = 'woocommerce/product-filter-chips';
+const getFallbackDisplayStyleInsertionPoint = (
+ parentBlock: BlockInstance
+) => {
+ const groupBlock = parentBlock.innerBlocks.find(
+ ( block ) => block.name === 'core/group'
+ );
+
+ if ( groupBlock ) {
+ return {
+ rootClientId: groupBlock.clientId,
+ index: groupBlock.innerBlocks.length,
+ };
+ }
+
+ return {
+ rootClientId: parentBlock.clientId,
+ index: parentBlock.innerBlocks.length,
+ };
+};
+
interface Attributes {
className?: string;
displayStyle: string;
@@ -171,7 +191,11 @@ export default function AttributeItemTemplateEdit(
label={ __( 'Style', 'woocommerce' ) }
resetAll={ () => {
setAttributes( { displayStyle: INNER_CHIPS } );
- resetDisplayStyleBlock( clientId, INNER_CHIPS );
+ resetDisplayStyleBlock(
+ clientId,
+ INNER_CHIPS,
+ getFallbackDisplayStyleInsertionPoint
+ );
} }
>
<ToolsPanelItem
@@ -179,7 +203,11 @@ export default function AttributeItemTemplateEdit(
label={ __( 'Style', 'woocommerce' ) }
onDeselect={ () => {
setAttributes( { displayStyle: INNER_CHIPS } );
- resetDisplayStyleBlock( clientId, INNER_CHIPS );
+ resetDisplayStyleBlock(
+ clientId,
+ INNER_CHIPS,
+ getFallbackDisplayStyleInsertionPoint
+ );
} }
isShownByDefault
>
@@ -190,6 +218,9 @@ export default function AttributeItemTemplateEdit(
<DisplayStyleSwitcher
clientId={ clientId }
currentStyle={ displayStyle }
+ getFallbackDisplayStyleInsertionPoint={
+ getFallbackDisplayStyleInsertionPoint
+ }
onChange={ ( value ) => {
setAttributes( {
displayStyle: value,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
index 37a2be40114..53a661ef35c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
@@ -11,7 +11,10 @@
"woocommerce/add-to-cart-with-options-variation-selector-attribute"
],
"supports": {
- "interactivity": true
+ "interactivity": true,
+ "woocommerce": {
+ "innerBlockDisplayStyle": true
+ }
},
"usesContext": [ "woocommerce/selectableItems", "woocommerce/attributeId" ],
"attributes": {},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx
deleted file mode 100644
index 9bbbaa61368..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * External dependencies
- */
-import { createBlock, getBlockTypes } from '@wordpress/blocks';
-import { useState } from '@wordpress/element';
-import { dispatch, select, useDispatch } from '@wordpress/data';
-import { getInnerBlockByName } from '@woocommerce/utils';
-import {
- // @ts-expect-error - no types.
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToggleGroupControl as ToggleGroupControl,
- // @ts-expect-error - no types.
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToggleGroupControlOption as ToggleGroupControlOption,
-} from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import { DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES } from '../../constants';
-
-function isDisplayStyleCandidate(
- blockTypeName: string,
- parentBlockName: string | undefined,
- blockAncestor: readonly string[] | undefined
-): boolean {
- if ( ! parentBlockName ) {
- return false;
- }
- if (
- DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES.includes( blockTypeName )
- ) {
- return false;
- }
- return blockAncestor?.includes( parentBlockName ) ?? false;
-}
-
-export const DisplayStyleSwitcher = ( {
- clientId,
- currentStyle,
- onChange,
-}: {
- clientId: string;
- currentStyle: string;
- onChange: ( value: string ) => void;
-} ) => {
- const filterBlock = select( 'core/block-editor' ).getBlock( clientId );
- const parentBlockName = filterBlock?.name;
-
- const displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
- isDisplayStyleCandidate(
- blockType.name,
- parentBlockName,
- blockType.ancestor
- )
- );
-
- const { insertBlock, replaceBlock } = useDispatch( 'core/block-editor' );
-
- const [ displayStyleBlocksAttributes, setDisplayStyleBlocksAttributes ] =
- useState< Record< string, unknown > >( {} );
-
- if ( displayStyleOptions.length === 0 ) return null;
-
- return (
- <ToggleGroupControl
- value={ currentStyle }
- isBlock
- __nextHasNoMarginBottom
- __next40pxDefaultSize
- label=""
- hideLabelFromVision
- onChange={ ( value: string | number | undefined ) => {
- if ( ! value || typeof value !== 'string' ) return;
- if ( ! filterBlock ) return;
- const currentStyleBlock = getInnerBlockByName(
- filterBlock,
- currentStyle
- );
-
- if ( currentStyleBlock ) {
- setDisplayStyleBlocksAttributes( {
- ...displayStyleBlocksAttributes,
- [ currentStyle ]: currentStyleBlock.attributes,
- } );
- replaceBlock(
- currentStyleBlock.clientId,
- createBlock(
- value,
- displayStyleBlocksAttributes[ value ] || {}
- )
- );
- } else {
- insertBlock(
- createBlock( value ),
- filterBlock.innerBlocks.length,
- filterBlock.clientId,
- false
- );
- }
- onChange( value );
- } }
- style={ { width: '100%' } }
- >
- { displayStyleOptions.map( ( blockType ) => (
- <ToggleGroupControlOption
- key={ blockType.name }
- label={ blockType.title }
- value={ blockType.name }
- />
- ) ) }
- </ToggleGroupControl>
- );
-};
-
-export function resetDisplayStyleBlock(
- clientId: string,
- defaultStyle: string
-) {
- const filterBlock = select( 'core/block-editor' ).getBlock( clientId );
- if ( ! filterBlock ) return;
-
- const parentBlockName = filterBlock.name;
- const displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
- isDisplayStyleCandidate(
- blockType.name,
- parentBlockName,
- blockType.ancestor
- )
- );
-
- const currentStyle = displayStyleOptions.find( ( blockType ) =>
- getInnerBlockByName( filterBlock, blockType.name )
- );
-
- const currentStyleBlock = currentStyle
- ? getInnerBlockByName( filterBlock, currentStyle.name )
- : null;
-
- const { insertBlock, replaceBlock } = dispatch( 'core/block-editor' );
- if ( currentStyleBlock ) {
- replaceBlock( currentStyleBlock.clientId, createBlock( defaultStyle ) );
- } else {
- insertBlock(
- createBlock( defaultStyle ),
- filterBlock.innerBlocks.length,
- filterBlock.clientId,
- false
- );
- }
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
index d4a91c2ef7f..6c26a0f9c2b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
@@ -9,11 +9,3 @@ export const EXCLUDED_BLOCKS = [
'woocommerce/add-to-cart-with-options',
'core/query',
];
-
-/**
- * Block types that declare a product filter or variation-selector parent in `ancestor`
- * but are not interchangeable display styles for the style toggle (chips vs dropdown).
- */
-export const DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES: string[] = [
- 'woocommerce/add-to-cart-with-options-variation-selector-attribute-name',
-];
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/inspector.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/inspector.tsx
index a82ca92832f..78bb15bcf62 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/inspector.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/inspector.tsx
@@ -4,7 +4,10 @@
import { InspectorControls } from '@wordpress/block-editor';
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
-import { Block, getBlockTypes } from '@wordpress/blocks';
+import {
+ DisplayStyleSwitcher,
+ resetDisplayStyleBlock,
+} from '@woocommerce/editor-components/display-style-switcher';
import {
SelectControl,
ToggleControl,
@@ -24,12 +27,6 @@ import {
import { sortOrderOptions, sortOrders } from './constants';
import { EditProps, DEFAULT_SORT_ORDER, DEFAULT_QUERY_TYPE } from './types';
import metadata from './block.json';
-import {
- DisplayStyleSwitcher,
- resetDisplayStyleBlock,
-} from '../../components/display-style-switcher';
-
-let displayStyleOptions: Block[] = [];
export const Inspector = ( {
clientId,
@@ -39,14 +36,6 @@ export const Inspector = ( {
const { sortOrder, queryType, displayStyle, showCounts, hideEmpty } =
attributes;
- if ( displayStyleOptions.length === 0 ) {
- displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
- blockType.ancestor?.includes(
- 'woocommerce/product-filter-attribute'
- )
- );
- }
-
return (
<>
<InspectorControls key="inspector">
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/block.json
index d674971bd2f..f3d3715c64c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/block.json
@@ -14,7 +14,10 @@
"woocommerce/product-filter-rating"
],
"supports": {
- "interactivity": true
+ "interactivity": true,
+ "woocommerce": {
+ "innerBlockDisplayStyle": true
+ }
},
"usesContext": [ "woocommerce/selectableItems" ],
"attributes": {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json
index 5f8d3aeda57..c3a446d6b44 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json
@@ -14,7 +14,10 @@
"woocommerce/add-to-cart-with-options-variation-selector-attribute"
],
"supports": {
- "interactivity": true
+ "interactivity": true,
+ "woocommerce": {
+ "innerBlockDisplayStyle": true
+ }
},
"usesContext": [ "woocommerce/selectableItems", "woocommerce/attributeId" ],
"attributes": {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/inspector.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/inspector.tsx
index b53673d289c..82fd46e01a8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/inspector.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/inspector.tsx
@@ -3,6 +3,10 @@
*/
import { InspectorControls } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
+import {
+ DisplayStyleSwitcher,
+ resetDisplayStyleBlock,
+} from '@woocommerce/editor-components/display-style-switcher';
import {
ToggleControl,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
@@ -15,10 +19,6 @@ import {
* Internal dependencies
*/
import { EditProps } from './types';
-import {
- DisplayStyleSwitcher,
- resetDisplayStyleBlock,
-} from '../../components/display-style-switcher';
import metadata from './block.json';
export const Inspector = ( {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
index af893552865..7dc056dd22b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/inspector.tsx
@@ -5,6 +5,10 @@ import { InspectorControls } from '@wordpress/block-editor';
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
+import {
+ DisplayStyleSwitcher,
+ resetDisplayStyleBlock,
+} from '@woocommerce/editor-components/display-style-switcher';
import {
SelectControl,
ToggleControl,
@@ -18,10 +22,6 @@ import {
* Internal dependencies
*/
import type { EditProps } from './types';
-import {
- DisplayStyleSwitcher,
- resetDisplayStyleBlock,
-} from '../../components/display-style-switcher';
import metadata from './block.json';
// Get the list of taxonomies that support custom ordering (drag & drop in admin).
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/index.tsx
new file mode 100644
index 00000000000..59211fd9475
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/index.tsx
@@ -0,0 +1,266 @@
+/**
+ * External dependencies
+ */
+import {
+ createBlock,
+ getBlockTypes,
+ type BlockInstance,
+} from '@wordpress/blocks';
+import { useState } from '@wordpress/element';
+import { dispatch, select, useDispatch } from '@wordpress/data';
+import { getInnerBlockByName } from '@woocommerce/utils';
+import {
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+} from '@wordpress/components';
+
+const SELECTABLE_ITEMS_CONTEXT = 'woocommerce/selectableItems';
+
+type DisplayStyleInsertionPoint = {
+ rootClientId: string;
+ index: number;
+};
+
+type DisplayStyleBlockSupport = {
+ woocommerce?: {
+ innerBlockDisplayStyle?: unknown;
+ };
+};
+
+/**
+ * By default, the current parent block is the insertion point. For complex
+ * block compositions, the default insertion point can be an inner block of
+ * the parent, such as the Variation Attribute Selector block.
+ */
+type GetFallbackDisplayStyleInsertionPoint = (
+ parentBlock: BlockInstance
+) => DisplayStyleInsertionPoint;
+
+type DisplayStyleBlockType = ReturnType< typeof getBlockTypes >[ number ] & {
+ ancestor?: readonly string[] | string;
+ usesContext?: readonly string[] | string;
+ supports?: DisplayStyleBlockSupport;
+};
+
+type DisplayStyleSwitcherProps = {
+ clientId: string;
+ currentStyle: string;
+ onChange: ( value: string ) => void;
+ contextKey?: string;
+ getFallbackDisplayStyleInsertionPoint?: GetFallbackDisplayStyleInsertionPoint;
+};
+
+function isBlockInstance(
+ block: BlockInstance | null
+): block is BlockInstance {
+ return Boolean( block );
+}
+
+function getBlockTypeList(
+ value: readonly string[] | string | undefined
+): readonly string[] {
+ if ( ! value ) {
+ return [];
+ }
+ return Array.isArray( value ) ? value : [ value ];
+}
+
+function hasInnerBlockDisplayStyleSupport(
+ blockType: DisplayStyleBlockType
+): boolean {
+ return blockType.supports?.woocommerce?.innerBlockDisplayStyle === true;
+}
+
+function isDisplayStyleCandidate(
+ blockType: DisplayStyleBlockType,
+ parentBlockName: string | undefined,
+ contextKey: string
+): boolean {
+ if ( ! parentBlockName ) {
+ return false;
+ }
+
+ if ( ! hasInnerBlockDisplayStyleSupport( blockType ) ) {
+ return false;
+ }
+
+ return (
+ getBlockTypeList( blockType.ancestor ).includes( parentBlockName ) &&
+ getBlockTypeList( blockType.usesContext ).includes( contextKey )
+ );
+}
+
+function getDisplayStyleOptions(
+ parentBlockName: string | undefined,
+ contextKey: string
+): DisplayStyleBlockType[] {
+ return ( getBlockTypes() as DisplayStyleBlockType[] ).filter(
+ ( blockType ) =>
+ isDisplayStyleCandidate( blockType, parentBlockName, contextKey )
+ );
+}
+
+function getCurrentDisplayStyleBlock(
+ parentBlock: BlockInstance,
+ displayStyleOptions: DisplayStyleBlockType[]
+): BlockInstance | null {
+ return (
+ displayStyleOptions
+ .map( ( blockType ) =>
+ getInnerBlockByName( parentBlock, blockType.name )
+ )
+ .find( isBlockInstance ) ?? null
+ );
+}
+
+function getDisplayStyleInsertionPoint(
+ parentBlock: BlockInstance,
+ getFallbackDisplayStyleInsertionPoint?: GetFallbackDisplayStyleInsertionPoint
+): DisplayStyleInsertionPoint {
+ return (
+ getFallbackDisplayStyleInsertionPoint?.( parentBlock ) ?? {
+ rootClientId: parentBlock.clientId,
+ index: parentBlock.innerBlocks.length,
+ }
+ );
+}
+
+export const DisplayStyleSwitcher = ( {
+ clientId,
+ currentStyle,
+ onChange,
+ contextKey = SELECTABLE_ITEMS_CONTEXT,
+ getFallbackDisplayStyleInsertionPoint,
+}: DisplayStyleSwitcherProps ) => {
+ const parentBlock = select( 'core/block-editor' ).getBlock( clientId );
+ const parentBlockName = parentBlock?.name;
+ const displayStyleOptions = getDisplayStyleOptions(
+ parentBlockName,
+ contextKey
+ );
+
+ const { insertBlock, replaceBlock } = useDispatch( 'core/block-editor' );
+
+ const [ displayStyleBlocksAttributes, setDisplayStyleBlocksAttributes ] =
+ useState< Record< string, Record< string, unknown > > >( {} );
+
+ if ( displayStyleOptions.length === 0 ) return null;
+
+ return (
+ <ToggleGroupControl
+ value={ currentStyle }
+ isBlock
+ __nextHasNoMarginBottom
+ __next40pxDefaultSize
+ label=""
+ hideLabelFromVision
+ onChange={ ( value: string | number | undefined ) => {
+ if ( ! value || typeof value !== 'string' ) {
+ return;
+ }
+ if ( ! parentBlock ) {
+ return;
+ }
+ if (
+ ! displayStyleOptions.some(
+ ( blockType ) => blockType.name === value
+ )
+ ) {
+ return;
+ }
+ const currentStyleBlock = getCurrentDisplayStyleBlock(
+ parentBlock,
+ displayStyleOptions
+ );
+
+ if ( currentStyleBlock ) {
+ const nextDisplayStyleBlocksAttributes = {
+ ...displayStyleBlocksAttributes,
+ [ currentStyleBlock.name ]:
+ currentStyleBlock.attributes,
+ };
+
+ setDisplayStyleBlocksAttributes(
+ nextDisplayStyleBlocksAttributes
+ );
+ replaceBlock(
+ currentStyleBlock.clientId,
+ createBlock(
+ value,
+ nextDisplayStyleBlocksAttributes[ value ] || {}
+ )
+ );
+ } else {
+ const insertionPoint = getDisplayStyleInsertionPoint(
+ parentBlock,
+ getFallbackDisplayStyleInsertionPoint
+ );
+
+ insertBlock(
+ createBlock( value ),
+ insertionPoint.index,
+ insertionPoint.rootClientId,
+ false
+ );
+ }
+ onChange( value );
+ } }
+ style={ { width: '100%' } }
+ >
+ { displayStyleOptions.map( ( blockType ) => (
+ <ToggleGroupControlOption
+ key={ blockType.name }
+ label={ blockType.title }
+ value={ blockType.name }
+ />
+ ) ) }
+ </ToggleGroupControl>
+ );
+};
+
+export function resetDisplayStyleBlock(
+ clientId: string,
+ defaultStyle: string,
+ getFallbackDisplayStyleInsertionPoint?: GetFallbackDisplayStyleInsertionPoint,
+ contextKey = SELECTABLE_ITEMS_CONTEXT
+) {
+ const parentBlock = select( 'core/block-editor' ).getBlock( clientId );
+ if ( ! parentBlock ) return;
+
+ const displayStyleOptions = getDisplayStyleOptions(
+ parentBlock.name,
+ contextKey
+ );
+
+ if (
+ ! displayStyleOptions.some(
+ ( blockType ) => blockType.name === defaultStyle
+ )
+ ) {
+ return;
+ }
+
+ const currentStyleBlock = getCurrentDisplayStyleBlock(
+ parentBlock,
+ displayStyleOptions
+ );
+
+ const { insertBlock, replaceBlock } = dispatch( 'core/block-editor' );
+ if ( currentStyleBlock ) {
+ replaceBlock( currentStyleBlock.clientId, createBlock( defaultStyle ) );
+ } else {
+ const insertionPoint = getDisplayStyleInsertionPoint(
+ parentBlock,
+ getFallbackDisplayStyleInsertionPoint
+ );
+
+ insertBlock(
+ createBlock( defaultStyle ),
+ insertionPoint.index,
+ insertionPoint.rootClientId,
+ false
+ );
+ }
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/test/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/test/index.tsx
new file mode 100644
index 00000000000..eeb67ff3904
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/display-style-switcher/test/index.tsx
@@ -0,0 +1,373 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { DisplayStyleSwitcher, resetDisplayStyleBlock } from '../index';
+
+type MockBlock = {
+ clientId: string;
+ name: string;
+ attributes?: Record< string, unknown >;
+ innerBlocks: MockBlock[];
+};
+
+type MockBlockType = {
+ name: string;
+ title: string;
+ ancestor?: string[];
+ usesContext?: string[];
+ supports?: Record< string, unknown >;
+};
+
+let mockBlockTypes: MockBlockType[] = [];
+let mockParentBlock: MockBlock | null = null;
+
+const mockCreateBlock = jest.fn(
+ ( name: string, attributes: Record< string, unknown > = {} ) => ( {
+ name,
+ attributes,
+ } )
+);
+const mockInsertBlock = jest.fn();
+const mockReplaceBlock = jest.fn();
+
+jest.mock( '@wordpress/blocks', () => ( {
+ createBlock: ( name: string, attributes?: Record< string, unknown > ) =>
+ mockCreateBlock( name, attributes ),
+ getBlockTypes: () => mockBlockTypes,
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ select: () => ( {
+ getBlock: () => mockParentBlock,
+ } ),
+ useDispatch: () => ( {
+ insertBlock: mockInsertBlock,
+ replaceBlock: mockReplaceBlock,
+ } ),
+ dispatch: () => ( {
+ insertBlock: mockInsertBlock,
+ replaceBlock: mockReplaceBlock,
+ } ),
+} ) );
+
+jest.mock( '@woocommerce/utils', () => ( {
+ getInnerBlockByName: ( block: MockBlock | null, name: string ) => {
+ if ( ! block ) {
+ return null;
+ }
+
+ for ( const innerBlock of block.innerBlocks ) {
+ if ( innerBlock.name === name ) {
+ return innerBlock;
+ }
+
+ const nestedBlock = jest
+ .requireMock( '@woocommerce/utils' )
+ .getInnerBlockByName( innerBlock, name );
+
+ if ( nestedBlock ) {
+ return nestedBlock;
+ }
+ }
+
+ return null;
+ },
+} ) );
+
+jest.mock( '@wordpress/components', () => {
+ const element = jest.requireActual( '@wordpress/element' );
+ return {
+ __experimentalToggleGroupControl: ( {
+ children,
+ onChange,
+ }: {
+ children: JSX.Element[];
+ onChange: ( value: string ) => void;
+ } ) =>
+ element.createElement(
+ 'div',
+ {},
+ element.Children.map( children, ( child: JSX.Element ) =>
+ element.cloneElement( child, { onSelect: onChange } )
+ )
+ ),
+ __experimentalToggleGroupControlOption: ( {
+ label,
+ value,
+ onSelect,
+ }: {
+ label: string;
+ value: string;
+ onSelect: ( value: string ) => void;
+ } ) =>
+ element.createElement(
+ 'button',
+ { type: 'button', onClick: () => onSelect( value ) },
+ label
+ ),
+ };
+} );
+
+const makeBlockType = ( overrides: Partial< MockBlockType > ) => ( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ ancestor: [ 'woocommerce/product-filter-attribute' ],
+ usesContext: [ 'woocommerce/selectableItems' ],
+ supports: {
+ woocommerce: {
+ innerBlockDisplayStyle: true,
+ },
+ },
+ ...overrides,
+} );
+
+describe( 'DisplayStyleSwitcher', () => {
+ beforeEach( () => {
+ mockBlockTypes = [];
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/product-filter-attribute',
+ innerBlocks: [],
+ };
+ mockCreateBlock.mockClear();
+ mockInsertBlock.mockClear();
+ mockReplaceBlock.mockClear();
+ } );
+
+ it( 'includes only blocks with display style support, matching ancestor, and matching context', () => {
+ mockBlockTypes = [
+ makeBlockType( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ } ),
+ makeBlockType( {
+ name: 'woocommerce/no-support',
+ title: 'No support',
+ supports: {},
+ } ),
+ makeBlockType( {
+ name: 'woocommerce/wrong-ancestor',
+ title: 'Wrong ancestor',
+ ancestor: [ 'woocommerce/other-parent' ],
+ } ),
+ makeBlockType( {
+ name: 'woocommerce/wrong-context',
+ title: 'Wrong context',
+ usesContext: [ 'woocommerce/removableItems' ],
+ } ),
+ ];
+
+ render(
+ <DisplayStyleSwitcher
+ clientId="parent-client-id"
+ currentStyle="woocommerce/product-filter-chips"
+ onChange={ jest.fn() }
+ />
+ );
+
+ expect( screen.getByRole( 'button', { name: 'Chips' } ) ).toBeVisible();
+ expect(
+ screen.queryByRole( 'button', { name: 'No support' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: 'Wrong ancestor' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: 'Wrong context' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'replaces the actual display style block when the attribute is stale', () => {
+ mockBlockTypes = [
+ makeBlockType( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ } ),
+ makeBlockType( {
+ name: 'woocommerce/product-filter-checkbox-list',
+ title: 'List',
+ } ),
+ ];
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/product-filter-attribute',
+ innerBlocks: [
+ {
+ clientId: 'chips-client-id',
+ name: 'woocommerce/product-filter-chips',
+ attributes: { chipText: 'blue' },
+ innerBlocks: [],
+ },
+ ],
+ };
+
+ render(
+ <DisplayStyleSwitcher
+ clientId="parent-client-id"
+ currentStyle="woocommerce/missing-style"
+ onChange={ jest.fn() }
+ />
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'List' } ) );
+
+ expect( mockReplaceBlock ).toHaveBeenCalledWith( 'chips-client-id', {
+ name: 'woocommerce/product-filter-checkbox-list',
+ attributes: {},
+ } );
+ expect( mockInsertBlock ).not.toHaveBeenCalled();
+ } );
+
+ it( 'restores attributes using the actual display style block name', () => {
+ mockBlockTypes = [
+ makeBlockType( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ } ),
+ makeBlockType( {
+ name: 'woocommerce/product-filter-checkbox-list',
+ title: 'List',
+ } ),
+ ];
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/product-filter-attribute',
+ innerBlocks: [
+ {
+ clientId: 'style-client-id',
+ name: 'woocommerce/product-filter-chips',
+ attributes: { chipText: 'blue' },
+ innerBlocks: [],
+ },
+ ],
+ };
+
+ const { rerender } = render(
+ <DisplayStyleSwitcher
+ clientId="parent-client-id"
+ currentStyle="woocommerce/missing-style"
+ onChange={ jest.fn() }
+ />
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'List' } ) );
+
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/product-filter-attribute',
+ innerBlocks: [
+ {
+ clientId: 'style-client-id',
+ name: 'woocommerce/product-filter-checkbox-list',
+ attributes: {},
+ innerBlocks: [],
+ },
+ ],
+ };
+ mockCreateBlock.mockClear();
+ mockReplaceBlock.mockClear();
+
+ rerender(
+ <DisplayStyleSwitcher
+ clientId="parent-client-id"
+ currentStyle="woocommerce/product-filter-checkbox-list"
+ onChange={ jest.fn() }
+ />
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Chips' } ) );
+
+ expect( mockCreateBlock ).toHaveBeenCalledWith(
+ 'woocommerce/product-filter-chips',
+ { chipText: 'blue' }
+ );
+ } );
+
+ it( 'uses fallback placement when no display style block exists', () => {
+ mockBlockTypes = [
+ makeBlockType( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ ancestor: [
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute',
+ ],
+ } ),
+ ];
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/add-to-cart-with-options-variation-selector-attribute',
+ innerBlocks: [
+ {
+ clientId: 'group-client-id',
+ name: 'core/group',
+ innerBlocks: [],
+ },
+ ],
+ };
+
+ render(
+ <DisplayStyleSwitcher
+ clientId="parent-client-id"
+ currentStyle="woocommerce/product-filter-chips"
+ getFallbackDisplayStyleInsertionPoint={ () => ( {
+ rootClientId: 'group-client-id',
+ index: 0,
+ } ) }
+ onChange={ jest.fn() }
+ />
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Chips' } ) );
+
+ expect( mockInsertBlock ).toHaveBeenCalledWith(
+ {
+ name: 'woocommerce/product-filter-chips',
+ attributes: {},
+ },
+ 0,
+ 'group-client-id',
+ false
+ );
+ } );
+
+ it( 'uses fallback placement when resetting without a display style block', () => {
+ mockBlockTypes = [
+ makeBlockType( {
+ name: 'woocommerce/product-filter-chips',
+ title: 'Chips',
+ ancestor: [
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute',
+ ],
+ } ),
+ ];
+ mockParentBlock = {
+ clientId: 'parent-client-id',
+ name: 'woocommerce/add-to-cart-with-options-variation-selector-attribute',
+ innerBlocks: [],
+ };
+
+ resetDisplayStyleBlock(
+ 'parent-client-id',
+ 'woocommerce/product-filter-chips',
+ () => ( {
+ rootClientId: 'group-client-id',
+ index: 0,
+ } )
+ );
+
+ expect( mockInsertBlock ).toHaveBeenCalledWith(
+ {
+ name: 'woocommerce/product-filter-chips',
+ attributes: {},
+ },
+ 0,
+ 'group-client-id',
+ false
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
index 1e4c99051dc..c8b2febccba 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
@@ -20,9 +20,33 @@ WooCommerce reusable inner blocks use small context protocols so they can render
- Inner blocks may derive presentation-only data locally. Parent data should not include child-owned fields such as list indexes.
- Server-rendered fallback items use `data-wp-each-child` with per-item `data-wp-context`; hydration reconciles them with the inner store.
+## Display styles
+
+A display style block is an inner block that renders a protocol context for a specific parent block. Display style blocks opt in through block support metadata:
+
+```json
+{
+ "supports": {
+ "woocommerce": {
+ "innerBlockDisplayStyle": true
+ }
+ }
+}
+```
+
+Display style blocks must:
+
+- Declare `supports.woocommerce.innerBlockDisplayStyle` as `true`.
+- Declare the protocol context in `usesContext`.
+- Declare the supported parent block in `ancestor`.
+- Render from protocol fields, not from parent-specific stores.
+- Treat extension fields as optional.
+
+The editor uses the support flag for discovery. `usesContext` and `ancestor` are validation signals so unrelated inner blocks do not appear in display style controls.
+
## Selectable Items
-Context key: `woocommerceSelectableItems`
+Context key: `woocommerce/selectableItems`
Used by selectable list UIs such as checkbox-list and chips.