Commit e7cd01f84c5 for woocommerce
commit e7cd01f84c50373c10b3cdce785720a588fb5839
Author: Tung Du <dinhtungdu@gmail.com>
Date: Wed May 20 08:58:23 2026 +0700
Decouple selectable inner block types (#65109)
* refactor: decouple selectable inner block types
* fix: keep selectable item base clean
* refactor: mirror selectable inner items
* refactor: inline selectable item extras
* fix: guard mirrored selectable stores
* simplify type
* fix: use hidden prop instaed of getter
* docs: update protocol
* docs: shorter protocol
* fix: support hidden in selectableItems
* fix: normalize selectable display limit
diff --git a/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types b/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types
new file mode 100644
index 00000000000..4035d5c8308
--- /dev/null
+++ b/plugins/woocommerce/changelog/35a20742-decouple-filter-inner-block-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Decouple selectable item inner block types from product filter domain types.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
index c0d12b62ebd..c08621ccfc7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
@@ -123,6 +123,37 @@ describe( 'product filters interactivity store', () => {
expect( mockRegisteredStore.state.selectableItems ).toEqual( [] );
} );
+ it( 'does not add child-owned index metadata to selectable items', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ mockGetServerContext.mockReturnValue( {
+ items: [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ type: 'attribute/color',
+ },
+ ],
+ activeFilters: [],
+ } );
+ mockGetContext.mockReturnValue( {
+ activeFilters: [],
+ } );
+
+ expect( mockRegisteredStore.state.selectableItems ).toEqual( [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ type: 'attribute/color',
+ selected: false,
+ },
+ ] );
+ } );
+
[
{
description: 'unicode value',
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
index 69585113643..69b52e26dde 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
@@ -149,9 +149,8 @@ const productFiltersStore = {
: getContext< ProductFiltersContext >();
const items = server.items;
if ( ! Array.isArray( items ) ) return [];
- return items.map( ( item, index ) => ( {
+ return items.map( ( item ) => ( {
...item,
- index,
selected: state.activeFilters.some(
( filter ) =>
filter.type === item.type && filter.value === item.value
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts
new file mode 100644
index 00000000000..3ddee197f15
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/__tests__/frontend.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Internal dependencies
+ */
+import type { CheckboxListStore } from '../frontend';
+
+const mockGetContext = jest.fn();
+const mockParentToggle = jest.fn();
+
+let mockRegisteredStore: CheckboxListStore | null = null;
+
+const mockParentStore = {
+ state: {
+ selectableItems: [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ },
+ {
+ id: 'attribute-red',
+ label: 'Red',
+ value: 'red',
+ selected: true,
+ },
+ ],
+ },
+ actions: {
+ toggle: mockParentToggle,
+ },
+};
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getContext: mockGetContext,
+ store: jest.fn( ( _name, definition ) => {
+ if ( definition ) {
+ mockRegisteredStore = definition;
+ return mockRegisteredStore;
+ }
+ return mockParentStore;
+ } ),
+ } ),
+ { virtual: true }
+);
+
+describe( 'product filter checkbox list interactivity store', () => {
+ beforeEach( () => {
+ jest.resetModules();
+ mockGetContext.mockReset();
+ mockParentToggle.mockReset();
+ mockRegisteredStore = null;
+
+ jest.isolateModules( () => {
+ require( '../frontend' );
+ } );
+ } );
+
+ it( 'mirrors parent selectable items with child-owned index metadata', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Checkbox list store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ } );
+
+ expect( mockRegisteredStore.state.items ).toEqual( [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ index: 0,
+ hidden: false,
+ },
+ {
+ id: 'attribute-red',
+ label: 'Red',
+ value: 'red',
+ selected: true,
+ index: 1,
+ hidden: false,
+ },
+ ] );
+ } );
+
+ it( 'uses the default display limit when context limit is invalid', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Checkbox list store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ displayLimit: -1,
+ isExpanded: false,
+ } );
+
+ expect( mockRegisteredStore.state.items[ 0 ].hidden ).toBe( false );
+ } );
+
+ it( 'forwards toggle to parent store with current item', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Checkbox list store was not registered.' );
+ }
+
+ const item = {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ index: 0,
+ };
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ item,
+ } );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( mockParentToggle ).toHaveBeenCalledWith( item );
+ } );
+
+ it( 'returns empty items when parent store data is missing', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Checkbox list store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {} );
+
+ expect( mockRegisteredStore.state.items ).toEqual( [] );
+ } );
+
+ it( 'does not forward toggle without current item', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Checkbox list store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ } );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( mockParentToggle ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
index f247eefc4d4..bf77a032050 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
@@ -6,9 +6,17 @@ import { store, getContext } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
+import type {
+ SelectableItem,
+ SelectableItemsParentStore,
+} from '../../../../types/type-defs/selectable-items';
-type ItemWithIndex = SelectableItem & { index?: number; color?: string };
+type CheckboxListItem = SelectableItem< {
+ color?: string;
+ index?: number;
+} >;
+
+const DEFAULT_DISPLAY_LIMIT = 15;
type CheckboxListContext = {
storeNamespace: string;
@@ -16,60 +24,83 @@ type CheckboxListContext = {
isExpanded: boolean;
};
-type ParentItemContext = {
- item?: ItemWithIndex;
-};
-
type CheckboxListStore = {
state: {
- itemHidden: boolean;
+ items: CheckboxListItem[];
ratingStyle: string;
colorSwatchStyle: string;
isColorSwatchEmpty: boolean;
};
actions: {
+ toggle: () => void;
showAll: () => void;
};
};
-function getParentItem( storeNamespace: string ): ItemWithIndex | undefined {
- const parentCtx = getContext< ParentItemContext >( storeNamespace );
- return parentCtx.item;
+function getParentStore( storeNamespace?: string ) {
+ if ( ! storeNamespace ) return undefined;
+ return store< SelectableItemsParentStore< { color?: string } > >(
+ storeNamespace
+ );
+}
+
+function normalizeDisplayLimit( displayLimit: number ): number {
+ const limit = Number( displayLimit );
+ if ( ! Number.isFinite( limit ) || limit < 0 ) {
+ return DEFAULT_DISPLAY_LIMIT;
+ }
+ return Math.floor( limit );
+}
+
+function getCurrentItem(): CheckboxListItem | undefined {
+ const context = getContext< { item?: CheckboxListItem } >();
+ return context.item;
}
const { state }: CheckboxListStore = store< CheckboxListStore >(
'woocommerce/product-filter-checkbox-list',
{
state: {
- get itemHidden(): boolean {
- const { isExpanded, storeNamespace, displayLimit } =
+ get items(): CheckboxListItem[] {
+ const { storeNamespace, isExpanded, displayLimit } =
getContext< CheckboxListContext >();
- if ( isExpanded ) return false;
- const item = getParentItem( storeNamespace );
- if ( ! item ) return false;
- if ( item.selected ) return false;
- if ( item.index === undefined ) return false;
- return item.index >= displayLimit;
+ const parentItems =
+ getParentStore( storeNamespace )?.state?.selectableItems;
+ if ( ! Array.isArray( parentItems ) ) return [];
+ const normalizedDisplayLimit =
+ normalizeDisplayLimit( displayLimit );
+ return parentItems.map( ( item, index ) => ( {
+ ...item,
+ index,
+ hidden:
+ item.hidden ||
+ ( ! isExpanded &&
+ ! item.selected &&
+ index >= normalizedDisplayLimit ),
+ } ) );
},
get ratingStyle(): string {
- const { storeNamespace } = getContext< CheckboxListContext >();
- const item = getParentItem( storeNamespace );
+ const item = getCurrentItem();
if ( ! item ) return '';
return `width: ${ Number( item.value ) * 20 }%`;
},
get colorSwatchStyle(): string {
- const { storeNamespace } = getContext< CheckboxListContext >();
- const item = getParentItem( storeNamespace );
+ const item = getCurrentItem();
if ( ! item?.color ) return '';
return `background-color: ${ item.color }`;
},
get isColorSwatchEmpty(): boolean {
- const { storeNamespace } = getContext< CheckboxListContext >();
- const item = getParentItem( storeNamespace );
+ const item = getCurrentItem();
return ! item?.color;
},
},
actions: {
+ toggle() {
+ const item = getCurrentItem();
+ if ( ! item ) return;
+ const { storeNamespace } = getContext< CheckboxListContext >();
+ getParentStore( storeNamespace )?.actions?.toggle?.( item );
+ },
showAll() {
const context = getContext< CheckboxListContext >();
context.isExpanded = true;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
index 4985f84b022..73d937dd493 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
@@ -7,7 +7,13 @@ import { BlockEditProps } from '@wordpress/blocks';
* Internal dependencies
*/
import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
-import type { Color, FilterItemFields } from '../../types';
+
+export type Color = {
+ slug?: string;
+ name?: string;
+ class?: string;
+ color: string;
+};
export type BlockAttributes = {
className: string;
@@ -22,7 +28,11 @@ export type BlockAttributes = {
};
export type EditProps = BlockEditProps< BlockAttributes > & {
- context: SelectableItemsBlockContext< FilterItemFields >;
+ context: SelectableItemsBlockContext< {
+ count?: number;
+ color?: string;
+ depth?: number;
+ } >;
optionElementBorder: Color;
setOptionElementBorder: ( value: string ) => void;
optionElementSelected: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
new file mode 100644
index 00000000000..b031b620259
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Internal dependencies
+ */
+import type { ChipsStore } from '../frontend';
+
+const mockGetContext = jest.fn();
+const mockParentToggle = jest.fn();
+
+let mockRegisteredStore: ChipsStore | null = null;
+
+const mockParentStore = {
+ state: {
+ selectableItems: [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ },
+ {
+ id: 'attribute-red',
+ label: 'Red',
+ value: 'red',
+ selected: true,
+ },
+ ],
+ },
+ actions: {
+ toggle: mockParentToggle,
+ },
+};
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getContext: mockGetContext,
+ store: jest.fn( ( _name, definition ) => {
+ if ( definition ) {
+ mockRegisteredStore = definition;
+ return mockRegisteredStore;
+ }
+ return mockParentStore;
+ } ),
+ } ),
+ { virtual: true }
+);
+
+describe( 'product filter chips interactivity store', () => {
+ beforeEach( () => {
+ jest.resetModules();
+ mockGetContext.mockReset();
+ mockParentToggle.mockReset();
+ mockRegisteredStore = null;
+
+ jest.isolateModules( () => {
+ require( '../frontend' );
+ } );
+ } );
+
+ it( 'mirrors parent selectable items with child-owned index metadata', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ } );
+
+ expect( mockRegisteredStore.state.items ).toEqual( [
+ {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ index: 0,
+ hidden: false,
+ },
+ {
+ id: 'attribute-red',
+ label: 'Red',
+ value: 'red',
+ selected: true,
+ index: 1,
+ hidden: false,
+ },
+ ] );
+ } );
+
+ it( 'uses the default display limit when context limit is invalid', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ displayLimit: -1,
+ isExpanded: false,
+ } );
+
+ expect( mockRegisteredStore.state.items[ 0 ].hidden ).toBe( false );
+ } );
+
+ it( 'forwards toggle to parent store with current item', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ const item = {
+ id: 'attribute-blue',
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ index: 0,
+ };
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ item,
+ } );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( mockParentToggle ).toHaveBeenCalledWith( item );
+ } );
+
+ it( 'returns empty items when parent store data is missing', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {} );
+
+ expect( mockRegisteredStore.state.items ).toEqual( [] );
+ } );
+
+ it( 'does not forward toggle without current item', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ mockGetContext.mockReturnValue( {
+ storeNamespace: 'woocommerce/product-filters',
+ } );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( mockParentToggle ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
index f153f9e5713..924bb9f0967 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
@@ -6,9 +6,17 @@ import { store, getContext } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
+import type {
+ SelectableItem,
+ SelectableItemsParentStore,
+} from '../../../../types/type-defs/selectable-items';
-type ChipsItem = SelectableItem< { color?: string } > & { index?: number };
+type ChipsItem = SelectableItem< {
+ color?: string;
+ index?: number;
+} >;
+
+const DEFAULT_DISPLAY_LIMIT = 15;
type ChipsContext = {
storeNamespace: string;
@@ -16,53 +24,77 @@ type ChipsContext = {
isExpanded: boolean;
};
-type ParentItemContext = {
- item?: ChipsItem;
-};
-
type ChipsStore = {
state: {
- itemHidden: boolean;
+ items: ChipsItem[];
swatchHidden: boolean;
swatchStyle: string;
};
actions: {
+ toggle: () => void;
showAll: () => void;
};
};
-function getParentItem( storeNamespace: string ): ChipsItem | undefined {
- const parentCtx = getContext< ParentItemContext >( storeNamespace );
- return parentCtx.item;
+function getParentStore( storeNamespace?: string ) {
+ if ( ! storeNamespace ) return undefined;
+ return store< SelectableItemsParentStore< { color?: string } > >(
+ storeNamespace
+ );
+}
+
+function normalizeDisplayLimit( displayLimit: number ): number {
+ const limit = Number( displayLimit );
+ if ( ! Number.isFinite( limit ) || limit < 0 ) {
+ return DEFAULT_DISPLAY_LIMIT;
+ }
+ return Math.floor( limit );
+}
+
+function getCurrentItem(): ChipsItem | undefined {
+ const context = getContext< { item?: ChipsItem } >();
+ return context.item;
}
const { state }: ChipsStore = store< ChipsStore >(
'woocommerce/product-filter-chips',
{
state: {
- get itemHidden(): boolean {
- const { isExpanded, storeNamespace, displayLimit } =
+ get items(): ChipsItem[] {
+ const { storeNamespace, isExpanded, displayLimit } =
getContext< ChipsContext >();
- if ( isExpanded ) return false;
- const item = getParentItem( storeNamespace );
- if ( ! item ) return false;
- if ( item.selected ) return false;
- if ( item.index === undefined ) return false;
- return item.index >= displayLimit;
+ const parentItems =
+ getParentStore( storeNamespace )?.state?.selectableItems;
+ if ( ! Array.isArray( parentItems ) ) return [];
+ const normalizedDisplayLimit =
+ normalizeDisplayLimit( displayLimit );
+ return parentItems.map( ( item, index ) => ( {
+ ...item,
+ index,
+ hidden:
+ item.hidden ||
+ ( ! isExpanded &&
+ ! item.selected &&
+ index >= normalizedDisplayLimit ),
+ } ) );
},
get swatchHidden(): boolean {
- const { storeNamespace } = getContext< ChipsContext >();
- const item = getParentItem( storeNamespace );
+ const item = getCurrentItem();
return ! item?.color;
},
get swatchStyle(): string {
- const { storeNamespace } = getContext< ChipsContext >();
- const item = getParentItem( storeNamespace );
+ const item = getCurrentItem();
if ( ! item?.color ) return '';
return `background-color: ${ item.color }`;
},
},
actions: {
+ toggle() {
+ const item = getCurrentItem();
+ if ( ! item ) return;
+ const { storeNamespace } = getContext< ChipsContext >();
+ getParentStore( storeNamespace )?.actions?.toggle?.( item );
+ },
showAll() {
const context = getContext< ChipsContext >();
context.isExpanded = true;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
index f70dcb35961..fbdcdc15cd5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
@@ -7,7 +7,13 @@ import { BlockEditProps } from '@wordpress/blocks';
* Internal dependencies
*/
import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
-import type { Color, FilterItemFields } from '../../types';
+
+export type Color = {
+ slug?: string;
+ name?: string;
+ class?: string;
+ color: string;
+};
export type BlockAttributes = {
className: string;
@@ -27,7 +33,10 @@ export type BlockAttributes = {
export type EditProps = BlockEditProps< BlockAttributes > & {
style: Record< string, string >;
- context: SelectableItemsBlockContext< FilterItemFields >;
+ context: SelectableItemsBlockContext< {
+ count?: number;
+ color?: string;
+ } >;
chipText: Color;
setChipText: ( value: string ) => void;
chipBackground: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
index c02d1b3b132..7a1daaf8efc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/types.ts
@@ -7,7 +7,13 @@ import type { BlockEditProps } from '@wordpress/blocks';
* Internal dependencies
*/
import type { RangeInputBlockContext } from '../../../../types/type-defs/range-input';
-import type { Color } from '../../types';
+
+type Color = {
+ slug?: string;
+ class?: string;
+ name?: string;
+ color: string;
+};
export type BlockAttributes = {
showInputFields: boolean;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index 780f01536a0..2432179baa6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -52,13 +52,3 @@ export type BlockAttributes = {
};
export type EditProps = BlockEditProps< BlockAttributes >;
-
-// ----------------------------------------
-// Editor color picker
-// ----------------------------------------
-export type Color = {
- slug?: string;
- class?: string;
- name?: string;
- color: string;
-};
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
index bd7140a662e..c98758a1612 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
@@ -11,6 +11,7 @@ export type SelectableItem< T = unknown > = (
value: string;
selected?: boolean;
disabled?: boolean;
+ hidden?: boolean;
type?: string;
} & T;
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 24bc99f617f..0c9deb05f35 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
@@ -1,289 +1,80 @@
# Inner Block Protocols
-> **Experimental:** These protocols are internal and subject to change without notice. Block context keys, action names, and type shapes documented here should not be considered a stable public API. They may be renamed, restructured, or removed in future releases.
+> **Experimental:** These protocols are internal and may change without notice.
-## Overview
+WooCommerce reusable inner blocks use small context protocols so they can render UI for different parent blocks without coupling to a specific parent store.
-This document defines the **context protocol pattern** used by WooCommerce blocks to let reusable inner blocks work with any parent store. Three concrete protocols exist today:
-
-| Context Key | Purpose | Inner Blocks |
+| Context key | Purpose | Inner blocks |
| --- | --- | --- |
-| `woocommerceSelectableItems` | Select/deselect items (filters, variations) | checkbox-list, chips |
-| `woocommerceRemovableItems` | Remove individual items (active filters) | removable-chips |
-| `woocommerceRangeInput` | Numeric range input (price, slider) | price-slider |
-
-Each protocol follows the same pattern — only item shape and action names differ.
-
-## Problem Statement
-
-WooCommerce blocks need reusable UI components (chips, swatches, pills, sliders) that can work inside multiple parent blocks with different Interactivity API stores. Without a protocol, inner blocks get tightly coupled to a single store namespace, preventing true reusability.
-
-## Solution: Context Protocol Pattern
-
-Inner blocks become **presentational** — they read a standardized context protocol and call parent-provided actions instead of directly referencing a specific store.
-
-```text
-┌─────────────────────────────────────────────────────────┐
-│ Protocol Specification (this document) │
-│ └── Defines the contract both sides must follow │
-├─────────────────────────────────────────────────────────┤
-│ Parent Block (implements protocol) │
-│ ├── Registers own Interactivity store │
-│ ├── Provides context matching protocol shape │
-│ ├── Implements fixed-name actions/getters │
-│ └── Handles business logic (filtering, variation, …) │
-├─────────────────────────────────────────────────────────┤
-│ Reusable Inner Block (consumes protocol) │
-│ ├── Reads context per protocol specification │
-│ ├── Renders UI based on context data │
-│ ├── Binds fixed-name actions/getters │
-│ └── Zero knowledge of parent's store/business logic │
-└─────────────────────────────────────────────────────────┘
-```
-
-## Shared Conventions
-
-All three protocols follow these rules:
-
-- **Context key** prefixed with `woocommerce` (e.g. `woocommerceSelectableItems`)
-- **`storeNamespace`** field on every context object — tells inner block which parent store to resolve `actions.*` / `state.*` against
-- **Fixed action & getter names** (not configurable via context fields) — inner blocks hardcode them
-- **TS contract interface** (`*ParentStore`) — parents assert conformance via `satisfies`
-- **Items-carrying contexts** (`selectableItems`, `removableItems`) — parent exposes items via a protocol-named getter (`state.selectableItems`, `state.removableItems`). Generic `state.items` is intentionally avoided so multiple protocols can coexist on the same store namespace without collision
-- **Nested `data-wp-interactive`** — inner blocks keep an outer scope under their own namespace (show-more and other presentational state) and nest an inner region under the parent namespace for `data-wp-each` + selection bindings. Presentational bindings on iterated items use cross-namespace `::` syntax back to the inner store (e.g. `data-wp-bind--hidden="<own-ns>::state.itemHidden"`). Inner store reads iteration context via `getContext(storeNamespace)` — no hardcoded parent reference
-- **SSR fallback via `data-wp-each-child`** — PHP renders the initially visible items (first `displayLimit`) once with `data-wp-each-child`, each carrying its own `data-wp-context` + live bindings so hydration wires them up. The template handles the remaining items client-side
-- **Inner block owns show-more** — default `displayLimit = 15`. Inner block exposes `state.itemHidden` (reads iteration `context.item.index` via cross-namespace `getContext(storeNamespace)`) and renders the show-more button. Parent never knows about show-more
-
-## Enforcement via TypeScript `satisfies`
-
-Every protocol ships a `*ParentStore` interface. Parent stores assert:
-
-```typescript
-import type { SelectableItemsParentStore } from '../../types/type-defs/selectable-items';
-// or: RemovableItemsParentStore, RangeInputParentStore
-
-myStore satisfies SelectableItemsParentStore;
-```
+| `woocommerceSelectableItems` | Select/deselect items | checkbox-list, chips |
+| `woocommerceRemovableItems` | Remove active items | removable-chips |
+| `woocommerceRangeInput` | Numeric range input | price-slider |
-Missing method/getter → compile error. No runtime cost.
+## Shared pattern
----
+- Parent provides protocol context and owns business state.
+- Inner block reads the protocol context, renders UI, and calls fixed parent actions.
+- Every context includes `storeNamespace`, which points to the parent Interactivity API store.
+- Parent stores expose protocol-scoped getters such as `state.selectableItems` or `state.removableItems`; generic `state.items` is avoided so protocols can coexist.
+- Parent stores should assert conformance with the matching `*ParentStore` TypeScript interface via `satisfies`.
+- 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.
-## Protocol: Selectable Items
+## Selectable Items
-### Context Key
+Context key: `woocommerceSelectableItems`
-```text
-woocommerceSelectableItems
-```
+Used by selectable list UIs such as checkbox-list and chips.
-Items are dynamic (computed at render time from database queries), so parent blocks do **not** use `providesContext` in block.json. Instead, they pass context directly when rendering inner blocks:
+Parents pass the context directly when rendering inner blocks because items are computed dynamically:
```php
-// Parent block render():
( new \WP_Block( $parsed_block, array(
'woocommerceSelectableItems' => $context,
) ) )->render();
```
-In the editor, parent blocks use `BlockContextProvider` to pass the same data:
-
-```jsx
-<BlockContextProvider value={ { 'woocommerceSelectableItems': context } }>
- { children }
-</BlockContextProvider>
-```
-
-#### Inner block.json (consumer)
-
-Inner blocks declare the context key they consume via `usesContext`, and which parents they can be nested inside via `ancestor`:
-
-```json
-{
- "name": "woocommerce/product-filter-checkbox-list",
- "usesContext": ["woocommerceSelectableItems"],
- "ancestor": [
- "woocommerce/product-filter-attribute",
- "woocommerce/product-filter-status",
- "woocommerce/product-filter-taxonomy",
- "woocommerce/product-filter-rating"
- ]
-}
-```
-
-Inner blocks receive the protocol data through `$block->context['woocommerceSelectableItems']` in PHP.
-
-### SelectableItemsContext
-
-The context object that parents MUST provide. Typed as `SelectableItemsContext<T>` where `T` is the extra fields the parent adds to each item (default: `unknown`).
-
-#### Core Fields (Required)
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `items` | `SelectableItem<T>[]` | **Yes** | Items to render |
-| `selectionMode` | `'single' \| 'multiple'` | **Yes** | Selection behavior |
-| `storeNamespace` | `string` | **Yes** | Parent's Interactivity API store |
-
-#### Accessibility Fields (Optional)
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `groupLabel` | `string` | No | Screen reader label for the group. Rendered as `<legend>` in fieldset. Example: "Filter by Color" |
-
-#### Presentation Fields (Optional)
-
-| Field | Type | Default | Description |
-| --- | --- | --- | --- |
-| `isLoading` | `boolean` | `false` | Parent is fetching items. Inner blocks show skeleton/loading state. |
-| `filterType` | `string` | `undefined` | Domain discriminator that inner blocks may use to vary presentation. For example, `'rating'` unlocks star rendering in `checkbox-list`. Values are parent-defined; unknown values fall back to text. |
-
-### SelectableItem
-
-`SelectableItem<T = unknown>` — base fields plus an optional generic extension `T` for domain-specific data.
-
-Each item in the `items` array MUST have:
-
-| Field | Type | Required | Description |
-| --- | --- | --- | --- |
-| `id` | `string` | **Yes** | Unique identifier for DOM element id. Format: `"{type}-{value}"` e.g. `"attribute-red"` |
-| `label` | `string \| HTML` | **Yes** | Display text or HTML (swatches, rating stars). HTML labels are emitted by the SSR `foreach` and preserved by `data-wp-each` via stable keys. |
-| `value` | `string` | **Yes** | Value for selection/submission |
-| `ariaLabel` | `string` | Conditional | **Required** if `label` contains HTML |
-| `selected` | `boolean` | No | Current selection state (default: false). SSR hint only — parent's `state.selectableItems` derives the live `selected` used for bindings. |
-| `disabled` | `boolean` | No | Whether item can be selected (default: false) |
-| `type` | `string` | No | Type discriminator (e.g., `"attribute/color"`) |
-
-Extra fields go in `T`. For product filters, `T = FilterItemFields`:
-
-```typescript
-type FilterItemFields = {
- count: number;
- termId?: number;
- parent?: number;
- depth?: number;
- menuOrder?: number;
-};
-
-type FilterOptionItem = SelectableItem<FilterItemFields>;
-```
-
-Inner blocks typed against a specific `T` access extra fields type-safely. Built-in inner blocks ignore unknown fields.
+In the editor, use `BlockContextProvider` with the same key.
-### Parent Store Requirements
-
-The store registered under `storeNamespace` MUST expose:
-
-| Name | Kind | Contract |
-| --- | --- | --- |
-| `state.selectableItems` | getter | Returns iterable of items with `selected: boolean` and `index: number` derived. Items come from `getServerContext().items` (so they refresh after navigation) merged with the client SSOT (`activeFilters`). Reactive — re-evaluates when SSOT changes. |
-| `actions.toggle` | action | Toggles selection for the target item. Accepts an optional `item` argument (used when an inner block proxies the call via its own store); when omitted, falls back to `getContext().item`. Mutates parent's SSOT (e.g. `activeFilters`). |
-
-Fixed names (not configurable). The getter is `selectableItems` (not `items`) to avoid colliding with other protocols (`removableItems`, etc.) when multiple protocols live on the same store namespace.
-
-Inner blocks iterate this getter directly via `data-wp-each--item="state.selectableItems"` inside a region that nests `data-wp-interactive="<parent-ns>"` (so `context.item` is set under the parent namespace, matching where `actions.toggle` and `context.item.selected` expect it). See _Inner Block Own Store_ below.
-
-Enforcement via TypeScript contract:
+### Context
```typescript
-import type { SelectableItemsParentStore } from '../../types/type-defs/selectable-items';
-
-const myStore = {
- state: { get selectableItems() { /* derive selected */ }, /* ... */ },
- actions: { toggle: ( item? ) => { /* target item, mutate SSOT */ }, /* ... */ },
-};
-
-// Compile-time check — TS error if `state.selectableItems` or `actions.toggle` is missing/wrong-shaped.
-myStore satisfies SelectableItemsParentStore;
+export interface SelectableItemsContext< T = unknown > {
+ items: SelectableItem< T >[];
+ selectionMode: 'single' | 'multiple';
+ storeNamespace: string;
+ groupLabel?: string;
+ isLoading?: boolean;
+ filterType?: string;
+}
```
-### Selection State Model
-
-- **SSOT** lives in parent's domain state (e.g. `context.activeFilters` for filters).
-- **Items rendered via `data-wp-each`** iterating the parent's `state.selectableItems` inside a nested-namespace region. PHP `foreach` with `data-wp-each-child` + per-item `data-wp-context` (including `index`) provides an SSR fallback for the items visible on first paint (first `displayLimit`); the rest are rendered client-side via the template.
-- **`item.selected`** on raw items (in `getContext().items`) is only a PHP SSR hint. Parent's `state.selectableItems` re-derives `selected` from SSOT for the live binding source.
-- **`actions.toggle`** mutates SSOT only. Never touches raw `item.selected`. It reads `getContext().item` which is set by `data-wp-each` under the parent namespace (the items region switches to the parent namespace precisely so this works).
-
-External mutations (active-filter removal, cross-block sync) flow through automatically: mutate `activeFilters` → `state.selectableItems` re-evaluates → every `context.item.selected` binding updates across all blocks.
-
-### Rendering Rules
+- `items` is the SSR/editor snapshot.
+- `storeNamespace` points to the live parent store used after hydration.
+- `groupLabel` is used for accessible fieldset labels.
+- `isLoading` and `filterType` are optional rendering hints.
-Inner blocks SHOULD:
-
-1. Render `<input type="radio">` when `selectionMode === 'single'`
-2. Render `<input type="checkbox">` when `selectionMode === 'multiple'`
-3. Set `disabled` attribute and apply reduced-opacity styling when `item.disabled === true`
-4. Use `groupLabel` for fieldset legend (screen reader accessible)
-5. Show skeleton/loading UI when `isLoading === true`
-6. Show items up to `displayLimit` (default 15), render show-more button when exceeded
-
-Inner blocks typed against `FilterItemFields` MAY additionally:
-
-1. Show counts when `item.count` exists
-
----
-
-## Design Rationale
-
-### Generic Extension Pattern
-
-`SelectableItem<T>` uses a generic parameter instead of a flat union of optional fields:
-
-- Base fields are shared by all consumers (id, label, value, selected, disabled, type)
-- Domain-specific fields live in `T` — typed, not untyped `[key: string]: unknown`
-- Filter blocks use `FilterOptionItem = SelectableItem<FilterItemFields>` with count, parent, depth, etc.
-- A variation selector would use `SelectableItem<{ price?: string; stockStatus?: string }>` etc.
-- TypeScript enforces correct shape at each call site with no extra runtime cost
-
-### Backward Compatibility
-
-`SelectableItem<T>` replaces the old flat `FilterOptionItem`. Key changes:
-
-| Old `FilterOptionItem` | New `SelectableItem<FilterItemFields>` |
-| --- | --- |
-| `id?: number` (optional, number) | `id: string` (required, string — used for DOM element id) |
-| `count: number` (required) | `count: number` in `FilterItemFields` (required for filters, absent for other consumers) |
-| No `disabled` | `disabled?: boolean` on base type |
-| No `type` | `type?: string` on base type |
-
----
-
-## Type Definitions
-
-This section provides copy-paste-ready type definitions for both TypeScript and PHP. These definitions enforce the protocol specification above.
-
-### TypeScript
-
-Location: `assets/js/types/type-defs/selectable-items.ts`
+### Item shape
```typescript
-import type { ReactNode } from 'react';
-
export type SelectableItem< T = unknown > = (
| { label: string; ariaLabel?: string }
| { label: ReactNode; ariaLabel: string }
) & {
- /** Unique key for DOM element id. Format: "{type}-{value}" */
id: string;
value: string;
selected?: boolean;
disabled?: boolean;
+ hidden?: boolean;
type?: string;
} & T;
+```
-export interface SelectableItemsContext< T = unknown > {
- items: SelectableItem< T >[];
- selectionMode: 'single' | 'multiple';
- storeNamespace: string;
- groupLabel?: string;
- isLoading?: boolean;
- filterType?: string;
-}
+`hidden` is optional protocol-level visibility metadata. Extra data belongs in `T`. Children may read optional extra fields, but missing fields must degrade safely.
-export type SelectableItemsBlockContext< T = unknown > = {
- 'woocommerceSelectableItems': SelectableItemsContext< T >;
-};
+### Parent store
+```typescript
export interface SelectableItemsParentStore< T = unknown > {
state: {
selectableItems: readonly SelectableItem< T >[];
@@ -294,438 +85,114 @@ export interface SelectableItemsParentStore< T = unknown > {
}
```
-Filter blocks extend with `FilterItemFields` (from `product-filters/types.ts`):
+Rules:
+
+- `state.selectableItems` is the live source after hydration.
+- `actions.toggle( item? )` updates the parent source of truth.
+- `hidden` may be provided by parents or derived by children to hide an item without removing it from the collection.
+- Parent must not add child-owned fields such as `index`.
+
+### Built-in consumers
+
+Product filters currently add these optional fields:
```typescript
export type FilterItemFields = {
- count: number;
+ count?: number;
termId?: number;
parent?: number;
depth?: number;
menuOrder?: number;
+ attributeQueryType?: 'and' | 'or';
+ color?: string;
};
-
-export type FilterOptionItem = SelectableItem< FilterItemFields >;
-```
-
-Inner blocks are typed via `SelectableItemsBlockContext<FilterItemFields>`:
-
-```typescript
-// In checkbox-list/types.ts or chips/types.ts
-export type EditProps = BlockEditProps< BlockAttributes > & {
- context: SelectableItemsBlockContext< FilterItemFields >;
- // ...color props
-};
-```
-
-### PHP
-
-No base class or trait needed — parent blocks set `$block->context` directly. PHPStan type aliases (defined below) enforce the structure at CI time.
-
-```php
-class ProductFilterAttribute extends AbstractBlock {
-
- protected function render( $attributes, $content, $block ) {
- $show_counts = $attributes['showCounts'] ?? false;
-
- /** @var SelectableItemsContext $context */
- $context = [
- // Items include 'count' only when $show_counts is true
- 'items' => $this->transform_to_selectable_items( $filter_items, $show_counts ),
- 'selectionMode' => 'multiple',
- 'storeNamespace' => 'woocommerce/product-filters',
- 'groupLabel' => $attributes['label'] ?? '',
- ];
-
- $block->context['woocommerceSelectableItems'] = $context;
-
- return sprintf(
- '<div %s>%s</div>',
- get_block_wrapper_attributes( [
- 'data-wp-interactive' => 'woocommerce/product-filters',
- ] ),
- $content
- );
- }
-}
-```
-
-#### PHPStan Type (for static analysis)
-
-Add to a dedicated types file (e.g. `phpstan.neon` or a project-level config). Do **not** add type aliases to `phpstan-baseline.neon` — the baseline is reserved for suppressing existing errors:
-
-```neon
-parameters:
- typeAliases:
- SelectableItem: '''
- array{
- id: string,
- label: string,
- value: string,
- ariaLabel?: string,
- selected?: bool,
- disabled?: bool,
- type?: string
- }
- '''
- FilterSelectableItem: '''
- array{
- id: string,
- label: string,
- value: string,
- ariaLabel?: string,
- selected?: bool,
- disabled?: bool,
- type?: string,
- count: int,
- termId?: int,
- parent?: int,
- depth?: int,
- menuOrder?: int
- }
- '''
- SelectableItemsContext: '''
- array{
- items: list<SelectableItem>,
- selectionMode: 'single'|'multiple',
- storeNamespace: string,
- groupLabel?: string,
- isLoading?: bool
- }
- '''
-```
-
----
-
-## Implementation Guide
-
-### Implementing as Inner Block (Consumer)
-
-Inner blocks consume the protocol. They render items using a `data-wp-each` template plus a PHP `foreach` SSR fallback, and reuse the parent's store via `storeNamespace` from context for selection bindings.
-
-block.json:
-
-```json
-{
- "name": "woocommerce/product-filter-checkbox-list",
- "usesContext": ["woocommerceSelectableItems"],
- "supports": {
- "interactivity": true
- }
-}
-```
-
-**frontend.ts** — Inner blocks need no frontend JS for selection. Selection action (`actions.toggle`) and live `context.item.selected` binding are provided by the parent store.
-
-#### Inner Block Own Store
-
-Inner blocks own presentational state (show-more toggle, per-item visibility, rendering variants). The parent store never learns about inner-block UI concerns. Two patterns are supported depending on how much control the inner block needs over items:
-
-**A. Direct (recommended default)** — iterate the parent's `state.selectableItems` via a nested `data-wp-interactive="<parent-ns>"` on the items region. `context.item` is set under the parent namespace, so `actions.toggle` and `context.item.selected` resolve directly. Presentational bindings use cross-namespace `::` back to the inner store. Simplest to reason about; reactive out of the box. Used by `checkbox-list` and `chips`.
-
-**B. Mirror** — inner store exposes its own `state.items` that wraps `store(storeNamespace).state.selectableItems` (mapping in extra fields like `hidden`) and its own `actions.toggle` that forwards to the parent. The whole template stays under the inner namespace — no nested scope switching. Choose this when the inner block needs to derive per-iteration data that doesn't belong to the parent contract (custom grouping, pre-sorting, enriched item shapes), or when binding values must resolve without cross-namespace syntax. Cross-store reactivity still works because signals chain through `store(ns).state.x`, but the extra indirection is the trade-off.
-
-Both patterns share the same outer wrapper and protocol plumbing. The walkthrough below uses pattern **A** (direct):
-
-- The **outer wrapper** runs in the inner namespace and carries `data-wp-context` with `storeNamespace` + `displayLimit` so the inner store can read them at runtime.
-- The **items region** nests a `data-wp-interactive="<parent-ns>"` so `data-wp-each--item="state.selectableItems"`, `data-wp-on--change="actions.toggle"`, and `data-wp-bind--checked="context.item.selected"` all resolve in the parent store (no cross-namespace syntax inside the iteration, `context.item` is set under the parent namespace which is what `state.selectableItems` and `actions.toggle` expect).
-- **Presentational bindings** that belong to the inner block use cross-namespace syntax back to the inner store: `data-wp-bind--hidden="<own-ns>::state.itemHidden"`, `data-wp-bind--style="<own-ns>::state.ratingStyle"`.
-- The **show-more button** lives outside the items region — under the inner namespace — and binds `data-wp-on--click="actions.showAll"` + `data-wp-bind--hidden="context.isExpanded"` directly.
-
-The inner store derives per-item fields from the iteration `context.item` provided by `data-wp-each`. Since that context is in the parent namespace, the inner getter uses dynamic `getContext(storeNamespace)` — no hardcoded parent reference, keeping the inner block reusable against any parent store.
-
-If pattern **B** (mirror) is chosen, the items region stays under the inner namespace (no nested `data-wp-interactive`), template iterates `data-wp-each--item="state.items"` (inner store), bindings read `context.item.selected` / `context.item.hidden` (fields added by the inner mirror), and the inner store defines `actions.toggle` that forwards to `store(storeNamespace).actions.toggle(item)`. Parent contract `actions.toggle` can accept an optional `item` argument when it expects to be invoked via a mirror. Other sections below stay the same.
-
-```html
-<div
- data-wp-interactive="woocommerce/product-filter-checkbox-list"
- data-wp-context='{"storeNamespace":"woocommerce/product-filters","displayLimit":15}'
->
- <fieldset>
- <!-- Items region: nested into the parent namespace so wp-each + actions resolve to the parent. -->
- <div data-wp-interactive="woocommerce/product-filters">
- <template data-wp-each--item="state.selectableItems" data-wp-each-key="context.item.id">
- <div data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden">
- <input
- data-wp-bind--checked="context.item.selected"
- data-wp-on--change="actions.toggle"
- >
- <span data-wp-text="context.item.label"></span>
- </div>
- </template>
- <!-- foreach SSR fallback below: first N items only, with data-wp-each-child + per-item data-wp-context including `index`. -->
- </div>
- <button
- data-wp-on--click="actions.showAll"
- data-wp-bind--hidden="context.isExpanded"
- >
- Show more
- </button>
- </fieldset>
-</div>
-```
-
-```typescript
-// frontend.ts
-import { store, getContext } from '@wordpress/interactivity';
-import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
-
-type ItemWithIndex = SelectableItem & { index: number };
-
-type CheckboxListContext = {
- storeNamespace: string;
- displayLimit: number;
- isExpanded: boolean;
-};
-
-type ParentItemContext = {
- item?: ItemWithIndex;
-};
-
-const { state } = store( 'woocommerce/product-filter-checkbox-list', {
- state: {
- get itemHidden(): boolean {
- const { isExpanded, storeNamespace, displayLimit } =
- getContext< CheckboxListContext >();
- if ( isExpanded ) return false;
- // Cross-namespace context read: pulls the wp-each iteration
- // `context.item` (stored under the parent ns) without
- // hardcoding the parent namespace.
- const parentCtx =
- getContext< ParentItemContext >( storeNamespace );
- if ( ! parentCtx.item ) return false;
- return parentCtx.item.index >= displayLimit;
- },
- },
- actions: {
- showAll() {
- const context = getContext< CheckboxListContext >();
- context.isExpanded = true;
- },
- },
-}, { lock: true } );
-```
-
-**Why nested namespaces:** `data-wp-each` stores `context.item` under the namespace active at the `<template>` element. Parent's `actions.toggle` and `context.item.selected` resolve under the parent namespace, so the items region must switch to it. Presentational bindings (`itemHidden`, `ratingStyle`) live in the inner store and use cross-namespace `::` syntax to read iteration context through `getContext(storeNamespace)`.
-
-**Why protocol-specific getter names (`selectableItems` / `removableItems`):** multiple protocols frequently share the same store namespace (e.g. `woocommerce/product-filters` hosts both the selectable-items store and the active-filters removable-items store). A generic `state.items` name collides across protocols and silently overrides. Protocol-aligned names (`selectableItems`, `removableItems`) make both live on the same store without interference.
-
-**PHP Renderer** — Template for `data-wp-each` plus `foreach` for SSR of the first `displayLimit` items. Each SSR item carries its `index` in `data-wp-context` so the inner store's `itemHidden` getter can decide visibility on hydration.
-
-```php
-protected function render( $attributes, $content, $block ) {
- if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
- return '';
- }
-
- $block_context = $block->context['woocommerceSelectableItems'];
- $items = $block_context['items'] ?? array();
- $store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
- $display_limit = 15;
- $visible_items = array_slice( $items, 0, $display_limit, true );
- $has_more_items = count( $items ) > $display_limit;
-
- $wrapper_attributes = array(
- 'data-wp-interactive' => 'woocommerce/product-filter-checkbox-list',
- 'data-wp-context' => wp_json_encode( array(
- 'storeNamespace' => $store_namespace,
- 'displayLimit' => $display_limit,
- ) ),
- );
-
- ob_start();
- ?>
- <div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); ?>>
- <fieldset>
- <div data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>">
- <template data-wp-each--item="state.selectableItems" data-wp-each-key="context.item.id">
- <div data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden">
- <input
- type="checkbox"
- data-wp-bind--id="context.item.id"
- data-wp-bind--value="context.item.value"
- data-wp-bind--checked="context.item.selected"
- data-wp-on--change="actions.toggle"
- >
- <span data-wp-text="context.item.label"></span>
- </div>
- </template>
- <?php foreach ( $visible_items as $index => $item ) :
- $context_item = array_merge( $item, array( 'index' => $index ) );
- ?>
- <div
- data-wp-each-child
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); ?>
- data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
- >
- <input
- type="checkbox"
- id="<?php echo esc_attr( $item['id'] ); ?>"
- value="<?php echo esc_attr( $item['value'] ); ?>"
- <?php checked( ! empty( $item['selected'] ) ); ?>
- data-wp-bind--checked="context.item.selected"
- data-wp-on--change="actions.toggle"
- >
- <span><?php echo esc_html( $item['label'] ); ?></span>
- </div>
- <?php endforeach; ?>
- </div>
- <?php if ( $has_more_items ) : ?>
- <button
- data-wp-on--click="actions.showAll"
- data-wp-bind--hidden="context.isExpanded"
- >
- Show more
- </button>
- <?php endif; ?>
- </fieldset>
- </div>
- <?php
- return ob_get_clean();
-}
```
-Key points:
-
-- **`data-wp-each` template + `foreach` SSR fallback for first `displayLimit` items** — the template renders the rest client-side, so the initial HTML stays small while the full list is still available post-hydration
-- **Per-item `data-wp-context` on SSR items includes `index`** — the inner store's `state.itemHidden` reads it via `getContext(storeNamespace).item.index` to decide visibility; hydration then attaches live bindings (`checked`, `hidden`, `toggle`) to the exact DOM wp-each reconciles
-- **Nested `data-wp-interactive`** — outer wrapper under the inner namespace, items region switches to the parent namespace so wp-each + parent selection bindings resolve there; presentational bindings (`itemHidden`, `ratingStyle`) use cross-namespace `::` back to the inner store
-- **`filterType` discriminator** — inner block can branch rendering (e.g. stars for `'rating'`) without leaking presentation into the parent store
-
-Reference implementation: `ProductFilterCheckboxList.php`, `ProductFilterChips.php`, `checkbox-list/frontend.ts`, `chips/frontend.ts`
-
-### Implementing as Parent Block (Provider)
-
-Parent blocks provide the protocol context to their inner blocks.
-
-#### Filter Example (ProductFilterAttribute.php)
-
-```php
-class ProductFilterAttribute extends AbstractBlock {
-
- protected function render($attributes, $content, $block) {
- // ... existing filter logic to get items ...
- $show_counts = $attributes['showCounts'] ?? false;
-
- // Transform filter items to standardized context
- $selectable_context = [
- 'items' => $this->transform_to_selectable_items($filter_items, $attribute_name, $show_counts),
- 'selectionMode' => 'multiple',
- 'storeNamespace' => 'woocommerce/product-filters',
- 'groupLabel' => $attribute_label,
- ];
-
- // Provide context to inner blocks
- $block->context['woocommerceSelectableItems'] = $selectable_context;
-
- // Render inner blocks
- return sprintf(
- '<div %s>%s</div>',
- get_block_wrapper_attributes([
- 'data-wp-interactive' => 'woocommerce/product-filters',
- ]),
- $content
- );
- }
-}
-```
+| Consumer | Optional fields read | Fallback |
+| --- | --- | --- |
+| `checkbox-list` | `count`, `color`, `depth`, `filterType === 'rating'` | Text label, no count, no swatch, no indent |
+| `chips` | `count`, `color` | Text label, no count, no swatch |
----
+Checkbox-list and chips mirror parent items into child `state.items`, adding local `index` for show-more and setting `hidden` when an item should be hidden. Their templates bind overflow visibility with `context.item.hidden`.
-## Protocol: Removable Items
+## Removable Items
Context key: `woocommerceRemovableItems`
-Used for lists of items that can be removed individually (active filter chips) with a "clear all" control.
+Used by active-filter chips and similar removable item lists.
-### Context Shape
+### Context
```typescript
export interface RemovableItem {
- type: string; // domain discriminator (e.g. "attribute/color", "price")
- value: string;
- label: string; // display text
+ type: string;
+ value: string;
+ label: string;
}
export interface RemovableItemsContext {
- items: RemovableItem[]; // SSR snapshot — parent's state.removableItems is SSOT post-hydration
- storeNamespace: string;
+ items: RemovableItem[];
+ storeNamespace: string;
}
```
-### Parent Store Requirements
+### Parent store
```typescript
export interface RemovableItemsParentStore {
- state: {
- removableItems: readonly RemovableItem[]; // derived from parent's SSOT; reactive
- };
- actions: {
- remove: () => void; // remove current getContext().item
- removeAll: () => void; // clear all items
- };
+ state: {
+ removableItems: readonly RemovableItem[];
+ };
+ actions: {
+ remove: () => void;
+ removeAll: () => void;
+ };
}
```
-The getter is `removableItems` (not `items`) for the same reason `selectableItems` is protocol-scoped — multiple protocols (removable-items + selectable-items) routinely live on the same store namespace (e.g. `woocommerce/product-filters`).
-
-Parents assert: `myStore satisfies RemovableItemsParentStore;`
+Rendering pattern:
-### Rendering Pattern
+- Inner block iterates `state.removableItems` from the parent store.
+- SSR fallback renders `context.items` with `data-wp-each-child` and per-item context.
+- Per-item remove calls `actions.remove`; clear-all calls `actions.removeAll`.
-Inner block (`removable-chips`):
+Reference implementations: `ProductFilterRemovableChips.php`, `ProductFilterClearButton.php`, `inner-blocks/active-filters/frontend.ts`.
-- Wrap in `data-wp-interactive="<storeNamespace>"`
-- Iterate `state.removableItems` via `data-wp-each` for reactive rendering (items can be added/removed dynamically)
-- SSR fallback: `foreach` over `context.items` with per-item `data-wp-context` and `data-wp-each-child`
-- Per-item binding: `data-wp-on--click="actions.remove"`, label via `data-wp-text="context.item.label"`
-- Clear-all button: `data-wp-on--click="actions.removeAll"`
-
-Reference implementation: `ProductFilterRemovableChips.php`, `ProductFilterClearButton.php`, `inner-blocks/active-filters/frontend.ts`.
-
----
-
-## Protocol: Range Input
+## Range Input
Context key: `woocommerceRangeInput`
-Used for two-ended numeric range controls (price slider, generic range).
+Used by two-ended numeric controls such as price sliders.
-### Context Shape
+### Context
```typescript
export interface RangeInputContext {
- min: number;
- max: number;
- currentMin: number;
- currentMax: number;
- step?: number;
- storeNamespace: string;
- isLoading?: boolean;
+ min: number;
+ max: number;
+ currentMin: number;
+ currentMax: number;
+ step?: number;
+ storeNamespace: string;
+ isLoading?: boolean;
}
```
-### Parent Store Requirements
+### Parent store
```typescript
export interface RangeInputParentStore {
- actions: {
- setMin: ( event: Event ) => void;
- setMax: ( event: Event ) => void;
- };
+ actions: {
+ setMin: ( event: Event ) => void;
+ setMax: ( event: Event ) => void;
+ };
}
```
-Generic names (`setMin`/`setMax`) — not price-specific — so the protocol can host non-price range inputs in the future. Parents assert: `myStore satisfies RangeInputParentStore;`
-
-### Rendering Pattern
-
-Inner block (`price-slider`):
+Rendering pattern:
-- Wrap in `data-wp-interactive="<storeNamespace>"`
-- Two `<input type="range">`, one per bound
-- Min input: `data-wp-on--input="actions.setMin"`, `data-wp-bind--value="state.<minGetter>"` (parent decides getter — e.g. `state.minPrice`)
-- Max input: `data-wp-on--input="actions.setMax"`, analogous for max
-- Parent owns display formatting (currency, locale) via its own state getters
+- Inner block renders min/max inputs.
+- Inputs call parent `actions.setMin` and `actions.setMax`.
+- Parent owns formatting, validation, and display state.
-Reference implementation: `ProductFilterPriceSlider.php`, `inner-blocks/price-filter/frontend.ts`, `inner-blocks/price-slider/frontend.ts`.
+Reference implementations: `ProductFilterPriceSlider.php`, `inner-blocks/price-filter/frontend.ts`, `inner-blocks/price-slider/frontend.ts`.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
index c5561e88bed..624bc998a54 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
@@ -94,19 +94,15 @@ final class ProductFilterCheckboxList extends AbstractBlock {
<?php if ( ! empty( $block_context['groupLabel'] ) ) : ?>
<legend class="screen-reader-text"><?php echo esc_html( $block_context['groupLabel'] ); ?></legend>
<?php endif; ?>
- <div
- class="wc-block-product-filter-checkbox-list__items"
- data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
- >
+ <div class="wc-block-product-filter-checkbox-list__items">
<?php
- foreach ( $visible_items as $index => $item ) :
- $context_item = array_merge( $item, array( 'index' => $index ) );
+ foreach ( $visible_items as $item ) :
?>
<div
class="wc-block-product-filter-checkbox-list__item"
data-wp-each-child
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
+ <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ data-wp-bind--hidden="context.item.hidden"
>
<label
class="wc-block-product-filter-checkbox-list__label"
@@ -164,12 +160,12 @@ final class ProductFilterCheckboxList extends AbstractBlock {
</div>
<?php endforeach; ?>
<template
- data-wp-each--item="state.selectableItems"
+ data-wp-each--item="state.items"
data-wp-each-key="context.item.id"
>
<div
class="wc-block-product-filter-checkbox-list__item"
- data-wp-bind--hidden="woocommerce/product-filter-checkbox-list::state.itemHidden"
+ data-wp-bind--hidden="context.item.hidden"
>
<label
class="wc-block-product-filter-checkbox-list__label"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index 3d6adddff27..a0a87c1add5 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -90,13 +90,9 @@ final class ProductFilterChips extends AbstractBlock {
<?php if ( ! empty( $block_context['groupLabel'] ) ) : ?>
<legend class="screen-reader-text"><?php echo esc_html( $block_context['groupLabel'] ); ?></legend>
<?php endif; ?>
- <div
- class="wc-block-product-filter-chips__items"
- data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
- >
+ <div class="wc-block-product-filter-chips__items">
<?php
- foreach ( $visible_items as $index => $item ) :
- $context_item = array_merge( $item, array( 'index' => $index ) );
+ foreach ( $visible_items as $item ) :
?>
<button
class="wc-block-product-filter-chips__item"
@@ -110,10 +106,10 @@ final class ProductFilterChips extends AbstractBlock {
aria-checked="<?php echo ! empty( $item['selected'] ) ? 'true' : 'false'; ?>"
<?php disabled( ! empty( $item['disabled'] ) ); ?>
data-wp-each-child
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $context_item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
data-wp-bind--aria-checked="context.item.selected"
data-wp-bind--disabled="context.item.disabled"
- data-wp-bind--hidden="woocommerce/product-filter-chips::state.itemHidden"
+ data-wp-bind--hidden="context.item.hidden"
data-wp-on--click="actions.toggle"
>
<span class="wc-block-product-filter-chips__label">
@@ -138,7 +134,7 @@ final class ProductFilterChips extends AbstractBlock {
</button>
<?php endforeach; ?>
<template
- data-wp-each--item="state.selectableItems"
+ data-wp-each--item="state.items"
data-wp-each-key="context.item.id"
>
<button
@@ -150,7 +146,7 @@ final class ProductFilterChips extends AbstractBlock {
data-wp-bind--value="context.item.value"
data-wp-bind--aria-checked="context.item.selected"
data-wp-bind--disabled="context.item.disabled"
- data-wp-bind--hidden="woocommerce/product-filter-chips::state.itemHidden"
+ data-wp-bind--hidden="context.item.hidden"
data-wp-on--click="actions.toggle"
>
<span class="wc-block-product-filter-chips__label">