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.