Commit e3dc5275466 for woocommerce
commit e3dc5275466061c82dd1f5cd431891acdc3117e1
Author: Tung Du <dinhtungdu@gmail.com>
Date: Wed May 6 13:18:42 2026 +0700
Add product filters inner block protocols (#64585)
* Add Store-Agnostic Inner Blocks protocol specification
Introduces the `woocommerce/selectableItems` context protocol - a formal
contract between parent blocks and reusable inner blocks for rendering
selectable item lists (swatches, chips, dropdowns).
This enables shared UI components between Product Filters and Variation
Selector blocks, which currently use different Interactivity API stores.
The specification includes:
- Protocol definition with required/optional fields
- TypeScript interface definitions
- PHP interface for static analysis
- Implementation guide for both providers and consumers
- Migration path for existing blocks
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Refactor protocol: move showCounts to displayHints bag
- Move use-case-specific `showCounts` from core protocol to `displayHints`
- Add `DisplayHints` type for extensible presentation preferences
- Add `layout` hint for future use
- Document `groupLabel` purpose (accessibility: fieldset legend)
- Update all examples and implementation code
This keeps the core protocol tight (4 required fields) while allowing
use-case-specific hints via an extensible bag pattern.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add block.json examples for providesContext/usesContext wiring
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove layout from DisplayHints — inner block choice controls layout
Layout (list vs grid vs inline) is a fundamentally different rendering
strategy, not a parent preference. The inner block selection itself
is the layout decision.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Flatten showCounts to top-level field, remove AbstractSelectableItems
Drop the DisplayHints bag — showCounts is now a direct field on
SelectableItemsContext. Replace abstract class pattern with concrete
SelectableSwatches block that reads context directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Split PHP contract into interface + trait for context wiring
Interface defines the contract (get_selectable_items, get_selection_mode,
etc). Trait provides build_selectable_items_context() and
provide_selectable_items_context() so parent blocks don't duplicate
the assembly logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify PHP contract: drop interface, keep minimal trait
Single-method trait just injects context into $block->context.
Parent blocks build the array inline — no abstraction overhead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Drop trait — parent blocks set $block->context directly
No abstraction needed for a single array assignment.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add @var SelectableItemsContext annotation for PHPStan validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add SelectableItem types, extensible items, shared type location
- Create selectable-items.ts in shared type-defs directory
- Add [key: string]: unknown for extension-provided display fields
- Move type location from product-filters to shared types
- Note in spec that items are extensible by design
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove resolved open questions, add extensibility note to spec
Items are extensible — extensions can add arbitrary fields consumed
by custom inner blocks. Move TS type location to shared type-defs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update selectable-items.ts with extensible index signature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Refactor filter blocks to use woocommerce/selectableItems protocol
Migrate existing filter blocks from filterData context to the
standardized selectableItems protocol. No new blocks created —
existing checkbox-list and chips blocks now consume the protocol
context directly.
Parent blocks (attribute, rating, status, taxonomy):
- Build context with selectionMode, selectAction, storeNamespace
- Pass via woocommerce/selectableItems key (PHP and editor)
Inner blocks (checkbox-list, chips):
- Read from woocommerce/selectableItems context
- New SelectableItemsBlockContext type
Price and active filter blocks unchanged (different pattern).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove providesContext from spec — items are dynamic
Context is computed at render time, not stored as a block attribute.
Parent blocks pass it directly via WP_Block constructor (PHP) and
BlockContextProvider (editor). Updated examples to match.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify inner blocks: remove own stores, delegate to parent via context
Inner blocks (checkbox-list, chips) no longer maintain their own
Interactivity API stores or show-more logic. They read storeNamespace
and selectAction from the selectableItems context and delegate all
actions to the parent block's store.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use data-wp-each for inner block item rendering
Replace PHP foreach with data-wp-each templates + SSR fallback
(data-wp-each-child) in checkbox-list and chips inner blocks.
Items and showCounts are passed via Interactivity API context.
Fix array_values() for non-sequential keys (taxonomy term IDs).
Remove show-more CSS from inner block stylesheets.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update spec: inner block rendering uses data-wp-each, no own store
Document the data-wp-each template + data-wp-each-child SSR pattern,
array_values() requirement, and data-wp-text (not data-wp-html).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add dynamicItems flag for static item lists (rating, stock)
Inner blocks now check dynamicItems context flag:
- true (default): use data-wp-each for dynamic rendering
- false: plain PHP foreach, preserves HTML labels (SVG stars)
Rating and stock filters set dynamicItems: false since they
have fixed item counts and don't need show-more.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Rename spec to selectable-items-inner-blocks.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: count spacing
* Remove showCounts from protocol, use count field presence
Instead of passing showCounts in context, parent blocks now
conditionally include 'count' field in items. Inner blocks
check isset($item['count']) to decide whether to show counts.
Simpler: no count in item = no count shown.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Remove ariaLabel processing from inner blocks
ariaLabel is optional - parent provides it only when needed
(e.g., rating filter with SVG labels). Inner blocks output
aria-label attribute only if ariaLabel is set.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add show-more button to inner blocks
Restore show-more functionality that was lost during refactor.
- Add displayedItems/hasMoreItems state, showAll action to parent store
- Inner blocks use state.displayedItems with displayLimit context
- Button hidden when all items shown
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add show-more button to parent filter blocks
Move show-more logic from inner blocks to parent blocks (Attribute, Taxonomy):
- Parent sets `hidden: true` on items beyond display limit
- Parent puts `items` in data-wp-context
- Parent renders show-more button
- Inner blocks inherit items, render visible only for SSR
- Template uses data-wp-bind--hidden for client-side
Also:
- Add `id` field to all filter items for DOM/key consistency
- Rename taxonomy `id` to `termId` for hierarchical term ID
- Add array_values() to ensure JSON array output
- Add show-more button styling
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: restore taxonomy filter initial count limit
* Use context.item.selected instead of state.isFilterSelected
Inner blocks should not know about parent-specific state names.
Parent now updates item.selected on toggle, inner blocks bind to it.
Also simplified the protocol spec documentation.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix chips selected state styling
- Add initial aria-checked/checked attributes for SSR
- Add default selected chip style using theme colors
- Custom colors still override via .has-selected-chip-*-color classes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Remove frontend.ts from inner blocks
Inner blocks use parent's store - no JS needed.
Removed viewScriptModule from block.json as well.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add shared inner block protocols with generic types
- SelectableItem/SelectableItemsContext: generic T param for extensions
- RangeInputContext: new protocol for slider/range inputs
- ActiveFiltersContext: new protocol for active filter display
- FilterBlockContext: composes all 3 protocols (all optional)
- Inner blocks import from shared types, not product-filters
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Migrate inner blocks to use shared protocol context keys
- price-slider: use context['woocommerce/rangeInput'] with
min/max/currentMin/currentMax mapping
- removable-chips: use context['woocommerce/activeFilters'] with
activeFilters[].activeLabel
- checkbox-list, chips: type context with FilterItemFields for
count/depth access
- Add id field to preview items in taxonomy-filter, rating-filter,
attribute-filter
- Add menuOrder to FilterItemFields for sort utility
- Add isLoading to shared context types
- Remove unused FilterBlockContext from product-filters/types.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix context keys and showCounts handling in filter blocks
- Update block.json usesContext to use new protocol keys:
- price-slider: woocommerce/rangeInput
- removable-chips: woocommerce/activeFilters
- Fix price-filter to provide woocommerce/rangeInput context
- Update price-filter/utils.ts field names: min/max/currentMin/currentMax
- Fix active-filters to provide woocommerce/activeFilters context
- Update active-filters/constants.ts: label → activeLabel, add value
- Respect showCounts setting: only include count when enabled
- Remove showCounts from context (inner blocks check item.count presence)
- Add showCounts to dependency arrays for recalculation
- Fix sort-filter-options: fallback to name sort when count undefined
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Update PHP blocks to use new context keys
- ProductFilterPrice: provide woocommerce/rangeInput context with
min/max/currentMin/currentMax field names
- ProductFilterPriceSlider: read from woocommerce/rangeInput context
- ProductFilterActive: provide woocommerce/activeFilters context with
activeFilters/removeAction/storeNamespace
- ProductFilterRemovableChips: read from woocommerce/activeFilters context
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Update selectable items spec to match implementation
* Unify inner block protocols with fixed names and parent store contracts
* Generalize spec doc to cover all inner block protocols
* Simplify spec: PHP foreach, inner block owns show-more
- Remove dynamicItems context field (no longer needed)
- Remove hidden field from SelectableItem type
- Switch from data-wp-each to PHP foreach rendering
- Inner block owns show-more with default displayLimit=15
- Enables HTML labels (swatches, rating stars)
- Update TypeScript and PHPStan type definitions
- Clarify nested namespace pattern for selection bindings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Update checkbox-list and chips to own show-more state
- Use proper store namespace (woocommerce/product-filter-checkbox-list,
woocommerce/product-filter-chips) instead of parent namespace
- Remove dynamicItems/hidden logic, use PHP foreach only
- Add frontend.ts with show-more state for both blocks
- Add viewScriptModule to block.json for script loading
- Default displayLimit = 15, show-more button when exceeded
- Nested namespace pattern: outer wrapper = own store,
items region = parent namespace for selection bindings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix rating stars not showing in filter labels
wp_kses_post() strips SVG tags. Add get_allowed_label_html() method
that includes SVG/path elements for rating stars rendering.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Revert inner blocks to data-wp-each, mirror parent via inner store
Inner blocks (checkbox-list, chips) now iterate items with `data-wp-each`
under a single inner-namespace scope instead of a nested parent scope.
The inner store mirrors the parent contract via the dynamic `storeNamespace`
from its own `data-wp-context`, so it stays parent-agnostic while keeping
hydration working for `data-wp-each-child` SSR items.
Protocol contract names are now scoped per protocol to avoid collisions
on shared store namespaces: `state.selectableItems` (selectable-items)
and `state.removableItems` (removable-items). Inner blocks expose a
local `state.items` that wraps the parent getter and adds `hidden`
derived from `isExpanded` + `item.index`.
SSR only renders the first `displayLimit` items with per-item
`data-wp-context` + live bindings; the remainder is rendered client-side
by the template. Rating star rendering is gated by the new optional
`filterType` discriminator on `selectableItems` context, keeping
presentation concerns out of the parent store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Switch inner blocks to nested-interactive scope
Inner blocks (`checkbox-list`, `chips`) now nest a `data-wp-interactive`
region under the parent namespace for the items. `data-wp-each` iterates
`state.selectableItems` directly in the parent store, so `actions.toggle`
and `context.item.selected` resolve without any proxy. Presentational
bindings (`itemHidden`, `ratingStyle`) remain in the inner store and are
referenced via cross-namespace `<own-ns>::` syntax.
Parent `state.selectableItems` reads `items` from `getServerContext()` so
the list refreshes on every interactivity-router navigation (other
filters narrowing now works), while `activeFilters` keeps coming from
the client context (SSOT for selection).
Parent `actions.toggle` goes back to a no-argument shape and reads
`getContext().item` set by wp-each.
Documentation: spec now describes both the direct (default) and mirror
patterns for inner blocks and covers the `index` field on derived items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Style show-more button as a link
Plain transparent button with underline; removes the previous
button-chrome so the control reads as a link inside the filter UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Reformat touched files with prettier
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Tighten selectable-items type system
- `SelectableItemsParentStore<T>` is now generic so parents can assert
their item shape (`productFiltersStore satisfies
SelectableItemsParentStore<FilterItemFields>`)
- `DerivedSelectableItem<T>` adds a required `index: number`, letting
inner stores read `getContext(storeNamespace).item.index` without
runtime guards
- Drop the local `FilterItem` alias in `product-filters/frontend.ts` in
favour of the shared `FilterOptionItem`, and move `ActiveFilterItem`
/ `ProductFiltersContext` into `product-filters/types.ts`
(re-exported from `frontend.ts` for existing consumers)
- `FilterItemFields` now carries `attributeQueryType`, so
`FilterOptionItem` is the single source of truth for a filter item
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix clear button not rendering — use woocommerce/removableItems context
ProductFilterClearButton checked for obsolete filterData context key.
Parent block (ProductFilterActive) provides woocommerce/removableItems.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix CI failures: PHPStan type errors and markdown lint
- Cast wp_json_encode() to string in ProductFilterCheckboxList and
ProductFilterChips to satisfy get_block_wrapper_attributes() signature
- Update phpstan-baseline.neon with corrected counts and remove stale entries
- Fix markdown lint issues in inner-block-protocols.md: normalize tables
to compact style, add blank lines around fences/lists, fix heading levels
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix additional lint errors: naming convention, prettier, PHPCS
- Add eslint-disable for WordPress block context key naming convention
in range-input.ts, removable-items.ts, selectable-items.ts
- Fix prettier formatting in price-filter/frontend.ts
- Add //end if comment for long condition in ProductFilterAttribute.php
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix taxonomy-filter preview mode to respect showCounts attribute
Strip count property from preview items when showCounts is false,
so the inner block correctly shows/hides count indicators.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix CodeRabbit review issues: defensive guards and accessibility
- Add defensive fallback for selectableItems context in checkbox-list and chips
edit.tsx to prevent editor crashes when block is mis-nested
- Add focus styles to show-more button for keyboard accessibility
- Filter out invalid removable items (empty type/value) in ProductFilterActive
- Fix return type in ProductFilterPriceSlider (return '' instead of void)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix CodeRabbit follow-up: array default and hasActiveFilters sync
- Add default empty array for items to prevent .map() crash on non-array
- Use filtered $removable_items for hasActiveFilters state sync
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix product filter review feedback
* Simplify product filter count rendering
* Use first item for count template gating
* Add disabled state support to checkbox-list and chips blocks
- Add disabled attribute binding to SSR and template elements in both blocks
- Add disabled styling (opacity 0.5, cursor not-allowed) for both blocks
- Remove unused labelType field from inner-block-protocols spec
- Update spec rendering rules to document disabled behavior
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Clean up product-filters store type exports
- Remove unused ActiveFilterItem re-export from frontend.ts
- Inline satisfies at store declaration, removing dead variable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Rename block context keys to camelCase convention
Rename woocommerce/selectableItems → woocommerceSelectableItems,
woocommerce/removableItems → woocommerceRemovableItems,
woocommerce/rangeInput → woocommerceRangeInput.
Aligns with WordPress block context convention (camelCase without
slashes, matching postId, queryId, etc.). Removes eslint-disable
comments that were needed for the slash format.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify status filter items with filter-before-map
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix lint and PHPStan CI failures
Use dot notation and unquoted object keys for block context
identifiers to satisfy eslint prettier/prettier and dot-notation
rules. Reduce $block->context accesses in ProductFilterActive
and remove stale PHPStan baseline entry for PriceSlider.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add experimental notice to inner block protocols doc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: show selected items initially
* Add changelog
* Fix lint alignment warnings in product filter PHP files
* Address CodeRabbit review feedback for inner block protocols
* Fix satisfies check to validate only protocol subset
* Fix satisfies check for removable-items and range-input protocols
* Remove DerivedSelectableItem and fix runtime satisfies crashes
- Remove DerivedSelectableItem from protocol; use SelectableItem directly
- Fix satisfies expressions that invoked getters at module load time,
causing getContext() to crash outside reactive scope; use typeof-based
compile-time checks instead
- Update toggle to accept optional item arg with instanceof Event guard
to support both direct (context-based) and mirror (explicit item) patterns
- Fix taxonomy-filter edit to use WPTaxonomyTerm type and correct
term.id vs term.termId field usage
- Update RangeInputParentStore to use HTMLElementEvent<HTMLInputElement>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify compile-time protocol conformance checks
* Add missing Internal dependencies comment block in range-input.ts
Fixes @woocommerce/dependency-group lint error for the HTMLElementEvent
import added in the previous commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address CodeRabbit review feedback
- Harden removeItemLabel getter against malformed config/context values
- Unify count DOM structure between server-rendered foreach and
data-wp-each template in checkbox-list and chips PHP blocks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Guard against missing index in checkbox-list and chips itemHidden
index is a parent implementation detail, not part of the protocol.
When a parent store omits index, items degrade gracefully to always
visible instead of breaking show-more logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add missing id fields to editor preview item arrays
attribute-filter and active-filters preview constants were missing the
id field required by the selectable-items and removable-items interfaces.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-inner-block-protocols b/plugins/woocommerce/changelog/add-inner-block-protocols
new file mode 100644
index 00000000000..2ea6a14b639
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-inner-block-protocols
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add experimental inner block protocols for product filter blocks.
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 7821ab1a0de..c0d12b62ebd 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
@@ -52,6 +52,77 @@ describe( 'product filters interactivity store', () => {
} );
} );
+ it( 'ignores invalid selectable item payloads', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ const context = {
+ isOverlayOpened: false,
+ params: {},
+ activeFilters: [],
+ item: {
+ label: 'Blue',
+ value: 'blue',
+ selected: false,
+ count: 1,
+ },
+ activeLabelTemplate: '{{label}}',
+ filterType: 'attribute/color',
+ };
+
+ mockGetContext.mockReturnValue( context );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( context.activeFilters ).toEqual( [] );
+ } );
+
+ it( 'uses value as active filter label fallback', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ const context = {
+ isOverlayOpened: false,
+ params: {},
+ activeFilters: [],
+ item: {
+ type: 'attribute/color',
+ value: 'blue',
+ selected: false,
+ count: 1,
+ },
+ activeLabelTemplate: 'Color: {{label}}',
+ filterType: 'attribute/color',
+ };
+
+ mockGetContext.mockReturnValue( context );
+
+ mockRegisteredStore.actions.toggle();
+
+ expect( context.activeFilters ).toEqual( [
+ {
+ value: 'blue',
+ type: 'attribute/color',
+ activeLabel: 'Color: blue',
+ },
+ ] );
+ } );
+
+ it( 'returns no selectable items when server context items are not an array', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+
+ mockGetServerContext.mockReturnValue( {
+ items: 'invalid',
+ activeFilters: [],
+ } );
+
+ expect( mockRegisteredStore.state.selectableItems ).toEqual( [] );
+ } );
+
[
{
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 374cd63fc37..f9c0a20e8d5 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
@@ -7,22 +7,52 @@ import * as iAPI from '@wordpress/interactivity';
* Internal dependencies
*/
import { decodeHtmlEntities } from '../../utils/html-entities';
+import type { SelectableItemsParentStore } from '../../types/type-defs/selectable-items';
+import type {
+ ActiveFilterItem,
+ FilterItemFields,
+ FilterOptionItem,
+ ProductFiltersContext,
+} from './types';
const { getContext, store, getServerContext, getConfig } = iAPI;
const BLOCK_NAME = 'woocommerce/product-filters';
-function selectFilter() {
+type ValidFilterOptionItem = FilterOptionItem & {
+ type: string;
+ value: string;
+};
+
+function isValidFilterOptionItem(
+ item: FilterOptionItem
+): item is ValidFilterOptionItem {
+ return (
+ typeof item.type === 'string' &&
+ item.type.length > 0 &&
+ typeof item.value === 'string' &&
+ item.value.length > 0
+ );
+}
+
+function getFilterLabel( item: ValidFilterOptionItem ): string {
+ const label = item.ariaLabel ?? item.label;
+ return typeof label === 'string' && label.length > 0 ? label : item.value;
+}
+
+function selectFilter( item: ValidFilterOptionItem ) {
const context = getContext< ProductFiltersContext >();
- const newActiveFilter = {
- value: context.item.value,
- type: context.item.type,
- attributeQueryType: context.item.attributeQueryType,
+ const newActiveFilter: ActiveFilterItem = {
+ value: item.value,
+ type: item.type,
activeLabel: context.activeLabelTemplate.replace(
'{{label}}',
- context.item?.ariaLabel || context.item.label
+ getFilterLabel( item )
),
};
+ if ( item.attributeQueryType ) {
+ newActiveFilter.attributeQueryType = item.attributeQueryType;
+ }
const newActiveFilters = context.activeFilters.filter(
( activeFilter ) =>
! (
@@ -36,43 +66,13 @@ function selectFilter() {
context.activeFilters = newActiveFilters;
}
-function unselectFilter() {
- const { item } = getContext< ProductFiltersContext >();
+function unselectFilter( item: ValidFilterOptionItem ) {
actions.removeActiveFiltersBy(
( activeFilter ) =>
activeFilter.type === item.type && activeFilter.value === item.value
);
}
-type FilterItem = {
- label: string;
- ariaLabel?: string;
- value: string;
- selected: boolean;
- count: number;
- type: string;
- attributeQueryType?: 'and' | 'or' | undefined;
- id?: number;
- parent?: number;
- depth?: number;
-};
-
-export type ActiveFilterItem = Pick<
- FilterItem,
- 'type' | 'value' | 'attributeQueryType'
-> & {
- activeLabel: string;
-};
-
-export type ProductFiltersContext = {
- isOverlayOpened: boolean;
- params: Record< string, string >;
- activeFilters: ActiveFilterItem[];
- item: FilterItem;
- activeLabelTemplate: string;
- filterType: string;
-};
-
const productFiltersStore = {
state: {
get params() {
@@ -138,13 +138,24 @@ const productFiltersStore = {
uid: `${ item.type }/${ item.value }`,
} ) );
},
- get isFilterSelected() {
- const { activeFilters, item } =
- getContext< ProductFiltersContext >();
- return activeFilters.some(
- ( filter ) =>
- filter.type === item.type && filter.value === item.value
- );
+ get selectableItems() {
+ // Items are server-owned (narrow on every navigation); read
+ // from server context so they refresh post-navigation.
+ // `getContext()` soft-merges and would keep the stale client
+ // snapshot.
+ const server = getServerContext
+ ? getServerContext< ProductFiltersContext >()
+ : getContext< ProductFiltersContext >();
+ const items = server.items;
+ if ( ! Array.isArray( items ) ) return [];
+ return items.map( ( item, index ) => ( {
+ ...item,
+ index,
+ selected: state.activeFilters.some(
+ ( filter ) =>
+ filter.type === item.type && filter.value === item.value
+ ),
+ } ) );
},
},
actions: {
@@ -181,11 +192,20 @@ const productFiltersStore = {
( item ) => ! callback( item )
);
},
- toggleFilter: () => {
- if ( state.isFilterSelected ) {
- unselectFilter();
+ toggle: ( itemArg?: FilterOptionItem | Event ) => {
+ const context = getContext< ProductFiltersContext >();
+ const item =
+ itemArg && ! ( itemArg instanceof Event )
+ ? itemArg
+ : context.item;
+ if ( ! item || ! isValidFilterOptionItem( item ) ) return;
+ const isSelected = context.activeFilters.some(
+ ( f ) => f.type === item.type && f.value === item.value
+ );
+ if ( isSelected ) {
+ unselectFilter( item );
} else {
- selectFilter();
+ selectFilter( item );
}
actions.navigate();
},
@@ -245,6 +265,10 @@ const productFiltersStore = {
},
};
+// Compile-time protocol conformance check.
+// eslint-disable-next-line @typescript-eslint/no-unused-expressions
+productFiltersStore satisfies SelectableItemsParentStore< FilterItemFields >;
+
export type ProductFiltersStore = typeof productFiltersStore;
const { state, actions } = store< ProductFiltersStore >(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/constants.ts
index 958ad60ddc9..7d7c7476de2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/constants.ts
@@ -5,23 +5,33 @@ import { __ } from '@wordpress/i18n';
export const filtersPreview = [
{
+ id: 'color_blue',
type: __( 'Color', 'woocommerce' ),
+ value: 'blue',
label: __( 'Blue', 'woocommerce' ),
},
{
+ id: 'color_red',
type: __( 'Color', 'woocommerce' ),
+ value: 'red',
label: __( 'Red', 'woocommerce' ),
},
{
+ id: 'size_large',
type: __( 'Size', 'woocommerce' ),
+ value: 'large',
label: __( 'Large', 'woocommerce' ),
},
{
+ id: 'status_instock',
type: __( 'Status', 'woocommerce' ),
+ value: 'instock',
label: __( 'In stock', 'woocommerce' ),
},
{
+ id: 'status_onsale',
type: __( 'Status', 'woocommerce' ),
+ value: 'onsale',
label: __( 'On sale', 'woocommerce' ),
},
];
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx
index 6763ba09505..c643ffdb45e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx
@@ -14,6 +14,7 @@ import { InitialDisabled } from '../../components/initial-disabled';
import { EXCLUDED_BLOCKS } from '../../constants';
import { getAllowedBlocks } from '../../utils/get-allowed-blocks';
import { filtersPreview } from './constants';
+import type { RemovableItemsContext } from '../../../../types/type-defs/removable-items';
const Edit = () => {
const { children, ...innerBlocksProps } = useInnerBlocksProps(
@@ -32,9 +33,10 @@ const Edit = () => {
<InitialDisabled>
<BlockContextProvider
value={ {
- filterData: {
+ woocommerceRemovableItems: {
items: filtersPreview,
- },
+ storeNamespace: 'woocommerce/product-filters',
+ } satisfies RemovableItemsContext,
} }
>
{ children }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
index 3d54e2b52c7..2f2f94df122 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/frontend.ts
@@ -6,22 +6,39 @@ import { store, getContext, getConfig } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
+import type { ProductFiltersContext } from '../../types';
+import type { ProductFiltersStore } from '../../frontend';
import type {
- ActiveFilterItem,
- ProductFiltersContext,
- ProductFiltersStore,
-} from '../../frontend';
+ RemovableItem,
+ RemovableItemsParentStore,
+} from '../../../../types/type-defs/removable-items';
-type ActiveFiltersContext = {
- item: ActiveFilterItem;
+type RemovableItemContext = {
+ item: RemovableItem;
};
const activeFiltersStore = {
state: {
- get removeActiveFilterLabel() {
- const { item } = getContext< ActiveFiltersContext >();
+ get removableItems() {
+ const { activeFilters } = getContext< ProductFiltersContext >();
+ return activeFilters
+ .filter( ( f ) => !! f.value )
+ .map( ( f ) => ( {
+ id: f.type + '_' + f.value,
+ type: f.type,
+ value: f.value,
+ label: f.activeLabel,
+ } ) );
+ },
+ get removeItemLabel() {
+ const { item } = getContext< RemovableItemContext >();
const { removeLabelTemplate } = getConfig();
- return removeLabelTemplate.replace( '{{label}}', item.activeLabel );
+ const template =
+ typeof removeLabelTemplate === 'string'
+ ? removeLabelTemplate
+ : '{{label}}';
+ const label = typeof item?.label === 'string' ? item.label : '';
+ return template.replace( '{{label}}', label );
},
get hasActiveFilters() {
const { activeFilters } = getContext< ProductFiltersContext >();
@@ -29,13 +46,13 @@ const activeFiltersStore = {
},
},
actions: {
- removeAllActiveFilters: () => {
+ removeAll: () => {
const context = getContext< ProductFiltersContext >();
context.activeFilters = [];
actions.navigate();
},
- removeActiveFilter: () => {
- const { item } = getContext< ActiveFiltersContext >();
+ remove: () => {
+ const { item } = getContext< RemovableItemContext >();
actions.removeActiveFiltersBy(
( filter ) =>
filter.value === item.value && filter.type === item.type
@@ -45,6 +62,10 @@ const activeFiltersStore = {
},
};
+// Compile-time protocol conformance check.
+// eslint-disable-next-line @typescript-eslint/no-unused-expressions
+activeFiltersStore satisfies RemovableItemsParentStore;
+
const { actions } = store< ProductFiltersStore & typeof activeFiltersStore >(
'woocommerce/product-filters',
activeFiltersStore
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/constants.ts
index e535dfebc16..cd6df25f538 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/constants.ts
@@ -5,6 +5,7 @@ import { __ } from '@wordpress/i18n';
export const attributeOptionsPreview = [
{
+ id: 'blue',
label: __( 'Blue', 'woocommerce' ),
value: 'blue',
rawData: {
@@ -18,6 +19,7 @@ export const attributeOptionsPreview = [
},
},
{
+ id: 'gray',
label: __( 'Gray', 'woocommerce' ),
value: 'gray',
selected: true,
@@ -32,6 +34,7 @@ export const attributeOptionsPreview = [
},
},
{
+ id: 'green',
label: __( 'Green', 'woocommerce' ),
value: 'green',
rawData: {
@@ -45,6 +48,7 @@ export const attributeOptionsPreview = [
},
},
{
+ id: 'red',
label: __( 'Red', 'woocommerce' ),
value: 'red',
selected: true,
@@ -59,6 +63,7 @@ export const attributeOptionsPreview = [
},
},
{
+ id: 'yellow',
label: __( 'Yellow', 'woocommerce' ),
value: 'yellow',
rawData: {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
index 15fde83b991..7d1861d6d55 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
@@ -30,7 +30,8 @@ import { EditProps, isAttributeCounts } from './types';
import { getAttributeFromId } from './utils';
import { getAllowedBlocks } from '../../utils/get-allowed-blocks';
import { EXCLUDED_BLOCKS } from '../../constants';
-import { FilterOptionItem } from '../../types';
+import { FilterOptionItem, FilterItemFields } from '../../types';
+import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
import { InitialDisabled } from '../../components/initial-disabled';
import { Notice } from '../../components/notice';
import { sortFilterOptions } from '../../utils/sort-filter-options';
@@ -96,10 +97,11 @@ const Edit = ( props: EditProps ) => {
return true;
} )
.map( ( term, index ) => ( {
+ id: term.id.toString(),
label: term.name,
value: term.id.toString(),
selected: index === 0,
- count: term.count,
+ ...( showCounts && { count: term.count } ),
} ) );
setAttributeOptions(
@@ -200,14 +202,15 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- filterData: {
+ woocommerceSelectableItems: {
items:
attributeOptions.length === 0 && isPreview
? attributeOptionsPreview
: attributeOptions,
+ selectionMode: 'multiple' as const,
+ storeNamespace: 'woocommerce/product-filters',
isLoading,
- showCounts,
- },
+ } satisfies SelectableItemsContext< FilterItemFields >,
} }
>
{ children }
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 e3a21b25f90..e9010e3dc26 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
@@ -16,7 +16,7 @@
"supports": {
"interactivity": true
},
- "usesContext": [ "filterData" ],
+ "usesContext": [ "woocommerceSelectableItems" ],
"attributes": {
"optionElementBorder": {
"type": "string",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
index 7ff546c8535..b9ce18e7bf8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
@@ -49,8 +49,11 @@ const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
customOptionElement,
customLabelElement,
} = attributes;
- const { filterData } = context;
- const { isLoading, items, showCounts } = filterData;
+ const selectableItems = context?.woocommerceSelectableItems ?? {};
+ const isLoading = selectableItems.isLoading ?? false;
+ const items = Array.isArray( selectableItems.items )
+ ? selectableItems.items
+ : [];
const colorGradientSettings = useMultipleOriginColorsAndGradients();
const blockProps = useBlockProps( {
@@ -76,10 +79,6 @@ const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
) );
}, [] );
- if ( ! items ) {
- return <></>;
- }
-
const threshold = 15;
const isLongList = items.length > threshold;
@@ -130,7 +129,7 @@ const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
)
: item.label }
</span>
- { showCounts && (
+ { item.count !== undefined && (
<span className="wc-block-product-filter-checkbox-list__count">
{ ` (${ item.count })` }
</span>
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 835154d59da..204204e329d 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
@@ -1,17 +1,70 @@
/**
* External dependencies
*/
-import { getContext, store } from '@wordpress/interactivity';
+import { store, getContext } from '@wordpress/interactivity';
+
+/**
+ * Internal dependencies
+ */
+import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
+
+type ItemWithIndex = SelectableItem & { index?: number };
type CheckboxListContext = {
- showAll: boolean;
+ storeNamespace: string;
+ displayLimit: number;
+ isExpanded: boolean;
+};
+
+type ParentItemContext = {
+ item?: ItemWithIndex;
};
-store( 'woocommerce/product-filters', {
+type CheckboxListStore = {
+ state: {
+ itemHidden: boolean;
+ ratingStyle: string;
+ };
actions: {
- showAllListItems: () => {
- const context = getContext< CheckboxListContext >();
- context.showAll = true;
+ showAll: () => void;
+ };
+};
+
+function getParentItem( storeNamespace: string ): ItemWithIndex | undefined {
+ const parentCtx = getContext< ParentItemContext >( storeNamespace );
+ return parentCtx.item;
+}
+
+const { state }: CheckboxListStore = store< CheckboxListStore >(
+ 'woocommerce/product-filter-checkbox-list',
+ {
+ state: {
+ get itemHidden(): boolean {
+ const { isExpanded, storeNamespace, 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;
+ },
+ get ratingStyle(): string {
+ const { storeNamespace } = getContext< CheckboxListContext >();
+ const item = getParentItem( storeNamespace );
+ if ( ! item ) return '';
+ return `width: ${ Number( item.value ) * 20 }%`;
+ },
+ },
+ actions: {
+ showAll() {
+ const context = getContext< CheckboxListContext >();
+ context.isExpanded = true;
+ },
},
},
-} );
+ { lock: true }
+);
+
+export type { CheckboxListStore };
+export { state as checkboxListState };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
index 43786fac483..9212da868ad 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
@@ -64,6 +64,22 @@ input[type="checkbox"].wc-block-product-filter-checkbox-list__input:focus {
);
}
+input[type="checkbox"].wc-block-product-filter-checkbox-list__input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+
+ + .wc-block-product-filter-checkbox-list__mark {
+ opacity: 0.5;
+ }
+}
+
+.wc-block-product-filter-checkbox-list__label:has(
+ input.wc-block-product-filter-checkbox-list__input:disabled
+ ) {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
svg.wc-block-product-filter-checkbox-list__mark {
box-sizing: border-box;
display: none;
@@ -100,22 +116,40 @@ input[type="checkbox"].wc-block-product-filter-checkbox-list__input:checked {
.wc-block-product-filter-checkbox-list__text {
display: contents;
- color: var(
- --wc-product-filter-checkbox-list-label-element,
- currentColor
- );
+ color: var(--wc-product-filter-checkbox-list-label-element, currentColor);
}
.wc-block-product-filter-checkbox-list__count {
- display: contents;
white-space: nowrap;
}
-.wc-block-product-filter-checkbox-list__show-more {
- text-decoration: underline;
+.wc-block-product-filter-checkbox-list__stars {
+ display: inline-block;
+ height: 1.55em;
+ line-height: 0;
+ overflow: hidden;
+ color: var(--wc-product-filter-checkbox-list-label-element, currentColor);
+}
+
+.wc-block-product-filter-checkbox-list__stars-svg {
+ display: block;
+ width: auto;
+ height: 1.55em;
+}
+
+.wc-block-product-filter-checkbox-list__show-more-button {
appearance: none;
- font-family: inherit;
background: transparent;
- border: none;
+ border: 0;
padding: 0;
+ font: inherit;
+ font-size: 0.875em;
+ color: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
}
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 bd0e3d7c4c3..4985f84b022 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
@@ -6,7 +6,8 @@ import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
-import type { Color, FilterBlockContext } from '../../types';
+import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
+import type { Color, FilterItemFields } from '../../types';
export type BlockAttributes = {
className: string;
@@ -21,7 +22,7 @@ export type BlockAttributes = {
};
export type EditProps = BlockEditProps< BlockAttributes > & {
- context: FilterBlockContext;
+ context: SelectableItemsBlockContext< FilterItemFields >;
optionElementBorder: Color;
setOptionElementBorder: ( value: string ) => void;
optionElementSelected: Color;
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 2f9b9775c57..e1d50155a31 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
@@ -15,7 +15,7 @@
"supports": {
"interactivity": true
},
- "usesContext": [ "filterData" ],
+ "usesContext": [ "woocommerceSelectableItems" ],
"attributes": {
"chipText": {
"type": "string"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
index d286dfa5fd8..2a4d4f6ab02 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
@@ -52,8 +52,8 @@ const Edit = ( props: EditProps ): JSX.Element => {
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
- const { filterData } = context;
- const { isLoading, items, showCounts } = filterData;
+ const { isLoading = false, items = [] } =
+ context?.woocommerceSelectableItems ?? {};
const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-chips', {
@@ -106,7 +106,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
? decodeHtmlEntities( item.label )
: item.label }
</span>
- { showCounts && (
+ { item.count !== undefined && (
<span className="wc-block-product-filter-chips__count">
{ ` (${ item.count })` }
</span>
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 48741138954..c34f5beb393 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
@@ -1,25 +1,63 @@
/**
* External dependencies
*/
-import { getContext, store } from '@wordpress/interactivity';
+import { store, getContext } from '@wordpress/interactivity';
/**
* Internal dependencies
*/
-import setStyles from './set-styles';
+import type { SelectableItem } from '../../../../types/type-defs/selectable-items';
-export type ChipsContext = {
- showAll: boolean;
+type ItemWithIndex = SelectableItem & { index?: number };
+
+type ChipsContext = {
+ storeNamespace: string;
+ displayLimit: number;
+ isExpanded: boolean;
};
-// Set selected chips styles for proper contrast.
-setStyles();
+type ParentItemContext = {
+ item?: ItemWithIndex;
+};
-store( 'woocommerce/product-filters', {
+type ChipsStore = {
+ state: {
+ itemHidden: boolean;
+ };
actions: {
- showAllChips: () => {
- const context = getContext< ChipsContext >();
- context.showAll = true;
+ showAll: () => void;
+ };
+};
+
+function getParentItem( storeNamespace: string ): ItemWithIndex | undefined {
+ const parentCtx = getContext< ParentItemContext >( storeNamespace );
+ return parentCtx.item;
+}
+
+const { state }: ChipsStore = store< ChipsStore >(
+ 'woocommerce/product-filter-chips',
+ {
+ state: {
+ get itemHidden(): boolean {
+ const { isExpanded, storeNamespace, 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;
+ },
+ },
+ actions: {
+ showAll() {
+ const context = getContext< ChipsContext >();
+ context.isExpanded = true;
+ },
},
},
-} );
+ { lock: true }
+);
+
+export type { ChipsStore };
+export { state as chipsState };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
index ee3282b710a..6198b20642d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
@@ -27,7 +27,7 @@
&:hover {
background-color: color-mix(
in srgb,
- var(--wp--preset--color--contrast) 10%,
+ var(--wp--preset--color--contrast, currentColor) 10%,
transparent
);
}
@@ -44,6 +44,9 @@
}
.wc-block-product-filter-chips__item[aria-checked="true"] {
+ background: var(--wp--preset--color--contrast, currentColor);
+ color: var(--wp--preset--color--base, Canvas);
+ border-color: var(--wp--preset--color--contrast, currentColor);
.has-selected-chip-text-color & {
color: var(--wc-product-filter-chips-selected-text);
@@ -56,6 +59,15 @@
}
}
+.wc-block-product-filter-chips__item:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+
+ &:hover {
+ background: transparent;
+ }
+}
+
:where(.wc-block-product-filter-chips__label) {
display: inline-flex;
align-items: center;
@@ -67,15 +79,22 @@
}
.wc-block-product-filter-chips__count {
- display: contents;
white-space: nowrap;
}
.wc-block-product-filter-chips__show-more {
- text-decoration: underline;
appearance: none;
- font-family: inherit;
background: transparent;
- border: none;
+ border: 0;
padding: 0;
+ font: inherit;
+ font-size: 0.875em;
+ color: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
}
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 dc4307fe613..f70dcb35961 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
@@ -6,7 +6,8 @@ import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
-import type { Color, FilterBlockContext } from '../../types';
+import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
+import type { Color, FilterItemFields } from '../../types';
export type BlockAttributes = {
className: string;
@@ -26,7 +27,7 @@ export type BlockAttributes = {
export type EditProps = BlockEditProps< BlockAttributes > & {
style: Record< string, string >;
- context: FilterBlockContext;
+ context: SelectableItemsBlockContext< FilterItemFields >;
chipText: Color;
setChipText: ( value: string ) => void;
chipBackground: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/clear-button/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/clear-button/block.json
index 0f53f394144..ac8666d5a4a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/clear-button/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/clear-button/block.json
@@ -8,7 +8,7 @@
"textdomain": "woocommerce",
"apiVersion": 3,
"ancestor": [ "woocommerce/product-filter-active" ],
- "usesContext": [ "filterData" ],
+ "usesContext": [ "woocommerceRemovableItems" ],
"supports": {
"interactivity": true,
"inserter": true
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/edit.tsx
index 5d3285a71ec..517e6115238 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/edit.tsx
@@ -30,8 +30,8 @@ const Edit = () => {
<InitialDisabled>
<BlockContextProvider
value={ {
- filterData: {
- price: getPriceFilterData( data ),
+ woocommerceRangeInput: {
+ ...getPriceFilterData( data ),
isLoading,
},
} }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
index eb240bda4d8..15346432bb8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/frontend.ts
@@ -7,8 +7,10 @@ import * as iAPI from '@wordpress/interactivity';
/**
* Internal dependencies
*/
-import { ProductFiltersContext, ProductFiltersStore } from '../../frontend';
+import type { ProductFiltersContext } from '../../types';
+import type { ProductFiltersStore } from '../../frontend';
import { formatPrice, getCurrency } from '../../utils/price-currency';
+import type { RangeInputParentStore } from '../../../../types/type-defs/range-input';
const { store, getContext, getServerContext, getConfig } = iAPI;
@@ -160,17 +162,21 @@ const productFilterPriceStore = {
context.activeFilters.push( newActivePriceFilter );
}
},
- setMinPrice: ( e: HTMLElementEvent< HTMLInputElement > ) => {
+ setMin: ( e: HTMLElementEvent< HTMLInputElement > ) => {
const price = parseInt( e.target.value, 10 );
actions.setPrice( 'min', price );
},
- setMaxPrice: ( e: HTMLElementEvent< HTMLInputElement > ) => {
+ setMax: ( e: HTMLElementEvent< HTMLInputElement > ) => {
const price = parseInt( e.target.value, 10 );
actions.setPrice( 'max', price );
},
},
};
+// Compile-time protocol conformance check.
+// eslint-disable-next-line @typescript-eslint/no-unused-expressions
+productFilterPriceStore satisfies RangeInputParentStore;
+
export type ProductFilterPriceStore = typeof productFilterPriceStore;
const { state, actions } = store<
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/utils.ts
index 7b88083a206..1ce97d2e8b1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/utils.ts
@@ -20,10 +20,10 @@ export function getPriceFilterData(
) {
if ( ! objectHasProp( results, 'price_range' ) ) {
return {
- minPrice: 0,
- maxPrice: 0,
- minRange: 0,
- maxRange: 0,
+ currentMin: 0,
+ currentMax: 0,
+ min: 0,
+ max: 0,
};
}
@@ -43,9 +43,9 @@ export function getPriceFilterData(
: 0;
return {
- minPrice,
- maxPrice,
- minRange: minPrice,
- maxRange: maxPrice,
+ currentMin: minPrice,
+ currentMax: maxPrice,
+ min: minPrice,
+ max: maxPrice,
};
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/block.json
index c2991578ec2..05078a1f068 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/block.json
@@ -49,7 +49,7 @@
}
},
"ancestor": [ "woocommerce/product-filter-price" ],
- "usesContext": [ "filterData" ],
+ "usesContext": [ "woocommerceRangeInput" ],
"textdomain": "woocommerce",
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/edit.tsx
index 620fa99a3f1..aff4ae198b7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/edit.tsx
@@ -56,7 +56,8 @@ const PriceSliderEdit = ( {
customSlider,
} = attributes;
- const { isLoading, price } = context.filterData;
+ const rangeInput = context.woocommerceRangeInput;
+ const { isLoading } = rangeInput ?? {};
const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-price-slider', {
@@ -72,22 +73,18 @@ const PriceSliderEdit = ( {
const colorGradientSettings = useMultipleOriginColorsAndGradients();
- if ( isLoading ) {
+ if ( isLoading || ! rangeInput ) {
return <>{ __( 'Loading…', 'woocommerce' ) }</>;
}
- if ( ! price ) {
- return null;
- }
-
- const { minPrice, maxPrice, minRange, maxRange } = price;
+ const { min, max, currentMin, currentMax } = rangeInput;
const formattedMinPrice = formatPrice(
- minPrice,
+ currentMin,
getCurrency( { minorUnit: 0 } )
);
const formattedMaxPrice = formatPrice(
- maxPrice,
+ currentMax,
getCurrency( { minorUnit: 0 } )
);
@@ -253,16 +250,16 @@ const PriceSliderEdit = ( {
<input
type="range"
className="min"
- min={ minRange }
- max={ maxRange }
- defaultValue={ minPrice }
+ min={ min }
+ max={ max }
+ defaultValue={ currentMin }
/>
<input
type="range"
className="max"
- min={ minRange }
- max={ maxRange }
- defaultValue={ maxPrice }
+ min={ min }
+ max={ max }
+ defaultValue={ currentMax }
/>
</div>
<div className="wc-block-product-filter-price-slider__right text">
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
index 90dcc6e8598..b2b7575530c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-slider/frontend.ts
@@ -55,14 +55,14 @@ const productFilterPriceSliderStore = {
},
debounceSetMinPrice: debounceWithScope(
( e: HTMLElementEvent< HTMLInputElement > ) => {
- actions.setMinPrice( e );
+ actions.setMin( e );
actions.navigate();
},
1000
),
debounceSetMaxPrice: debounceWithScope(
( e: HTMLElementEvent< HTMLInputElement > ) => {
- actions.setMaxPrice( e );
+ actions.setMax( e );
actions.navigate();
},
1000
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 59afdad1c36..c02d1b3b132 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
@@ -6,7 +6,8 @@ import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
-import type { Color, FilterBlockContext } from '../../types';
+import type { RangeInputBlockContext } from '../../../../types/type-defs/range-input';
+import type { Color } from '../../types';
export type BlockAttributes = {
showInputFields: boolean;
@@ -23,7 +24,7 @@ export type BlockAttributes = {
};
export interface EditProps extends BlockEditProps< BlockAttributes > {
- context: FilterBlockContext;
+ context: RangeInputBlockContext;
sliderHandle: Color;
setSliderHandle: ( color: string ) => void;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/edit.tsx
index 14134413466..a981f4cc72a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/edit.tsx
@@ -29,6 +29,8 @@ import { getAllowedBlocks } from '../../utils/get-allowed-blocks';
import { EXCLUDED_BLOCKS } from '../../constants';
import { Notice } from '../../components/notice';
import type { Attributes } from './types';
+import type { FilterItemFields } from '../../types';
+import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
import { InitialDisabled } from '../../components/initial-disabled';
import RatingStars from './components/rating-stars';
@@ -120,6 +122,7 @@ const RatingFilterEdit = ( props: BlockEditProps< Attributes > ) => {
.sort( ( a, b ) => b.rating - a.rating )
.filter( ( { rating } ) => rating >= minimumRating )
.map( ( { rating, count }, index ) => ( {
+ id: `rating-${ rating }`,
label: <RatingStars key={ rating } stars={ rating } />,
ariaLabel: sprintf(
/* translators: %d: rating value. Example: Rated 4 out of 5. */
@@ -128,7 +131,7 @@ const RatingFilterEdit = ( props: BlockEditProps< Attributes > ) => {
),
value: rating?.toString(),
selected: index === 0,
- count,
+ ...( showCounts && { count } ),
} ) )
: [];
@@ -184,11 +187,13 @@ const RatingFilterEdit = ( props: BlockEditProps< Attributes > ) => {
>
<BlockContextProvider
value={ {
- filterData: {
+ woocommerceSelectableItems: {
items: displayedOptions,
+ selectionMode: 'multiple' as const,
+ storeNamespace:
+ 'woocommerce/product-filters',
isLoading,
- showCounts,
- },
+ } satisfies SelectableItemsContext< FilterItemFields >,
} }
>
{ children }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/preview.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/preview.tsx
index 74ae420b098..1196a017758 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/preview.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/preview.tsx
@@ -11,30 +11,35 @@ import RatingStars from './components/rating-stars';
export const previewOptions: FilterOptionItem[] = [
{
+ id: 'rating-5',
label: <RatingStars stars={ 5 } />,
ariaLabel: __( 'Rated 5 out of 5', 'woocommerce' ),
value: '5',
count: 35,
},
{
+ id: 'rating-4',
label: <RatingStars stars={ 4 } />,
ariaLabel: __( 'Rated 4 out of 5', 'woocommerce' ),
value: '4',
count: 20,
},
{
+ id: 'rating-3',
label: <RatingStars stars={ 3 } />,
ariaLabel: __( 'Rated 3 out of 5', 'woocommerce' ),
value: '3',
count: 3,
},
{
+ id: 'rating-2',
label: <RatingStars stars={ 2 } />,
ariaLabel: __( 'Rated 2 out of 5', 'woocommerce' ),
value: '2',
count: 6,
},
{
+ id: 'rating-1',
label: <RatingStars stars={ 1 } />,
ariaLabel: __( 'Rated 1 out of 5', 'woocommerce' ),
value: '1',
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/block.json
index 5b3d1a6247b..686ff058131 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/block.json
@@ -19,7 +19,7 @@
},
"interactivity": true
},
- "usesContext": [ "queryId", "filterData" ],
+ "usesContext": [ "queryId", "woocommerceRemovableItems" ],
"attributes": {
"chipText": {
"type": "string"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/edit.tsx
index 641b373e51c..15fcd60c1e2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/edit.tsx
@@ -44,8 +44,8 @@ const Edit = ( props: EditProps ): JSX.Element => {
} = props;
const { customChipText, customChipBackground, customChipBorder, layout } =
attributes;
- const { filterData } = context;
- const { items } = filterData;
+ const removableItemsContext = context.woocommerceRemovableItems;
+ const { items } = removableItemsContext ?? {};
// Extract attributes from block layout
const layoutBlockSupport = getBlockSupport( name, 'layout' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/types.ts
index 8b290ea6ff0..d7751154d76 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/removable-chips/types.ts
@@ -6,7 +6,7 @@ import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
-import { FilterBlockContext } from '../../types';
+import type { RemovableItemsBlockContext } from '../../../../types/type-defs/removable-items';
export type Color = {
slug?: string;
@@ -30,7 +30,7 @@ export type BlockAttributes = {
export type EditProps = BlockEditProps< BlockAttributes > & {
style: Record< string, string >;
- context: FilterBlockContext;
+ context: RemovableItemsBlockContext;
chipText: Color;
setChipText: ( value: string ) => void;
chipBackground: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/edit.tsx
index bf8036fb7b1..115438a43b3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/edit.tsx
@@ -17,6 +17,8 @@ import { getSetting } from '@woocommerce/settings';
import { InitialDisabled } from '../../components/initial-disabled';
import { Inspector } from './inspector';
import type { EditProps } from './types';
+import type { FilterItemFields } from '../../types';
+import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
const Edit = ( props: EditProps ) => {
const { showCounts, hideEmpty } = props.attributes;
@@ -57,6 +59,14 @@ const Edit = ( props: EditProps ) => {
const items = useMemo( () => {
return Object.entries( stockStatusOptions )
+ .filter( ( [ key ] ) => {
+ if ( ! hideEmpty ) return true;
+ const count =
+ filteredCounts?.stock_status_counts?.find(
+ ( item ) => item.status === key
+ )?.count ?? 0;
+ return count > 0;
+ } )
.map( ( [ key, value ], index ) => {
const count =
filteredCounts?.stock_status_counts?.find(
@@ -68,12 +78,11 @@ const Edit = ( props: EditProps ) => {
ariaLabel: value,
value: key,
selected: index === 0,
- count,
+ ...( showCounts && { count } ),
type: 'status',
};
- } )
- .filter( ( item ) => ! hideEmpty || item.count > 0 );
- }, [ stockStatusOptions, filteredCounts, hideEmpty ] );
+ } );
+ }, [ stockStatusOptions, filteredCounts, hideEmpty, showCounts ] );
return (
<div { ...innerBlocksProps }>
@@ -81,11 +90,12 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- filterData: {
+ woocommerceSelectableItems: {
items,
+ selectionMode: 'multiple' as const,
+ storeNamespace: 'woocommerce/product-filters',
isLoading,
- showCounts,
- },
+ } satisfies SelectableItemsContext< FilterItemFields >,
} }
>
{ children }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/constants.ts
index c300c3c02c8..df8dfd5f42d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/constants.ts
@@ -5,18 +5,21 @@ import { FilterOptionItem } from '../../types';
export const termOptionsPreview: FilterOptionItem[] = [
{
+ id: 'sample-item-1',
label: 'Sample Item 1',
value: 'sample-item-1',
selected: false,
count: 10,
},
{
+ id: 'sample-item-2',
label: 'Sample Item 2',
value: 'sample-item-2',
selected: true,
count: 12,
},
{
+ id: 'sample-item-3',
label: 'Sample Item 3',
value: 'sample-item-3',
selected: false,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
index 2c827dcda1e..8ffe6927d2c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/edit.tsx
@@ -22,18 +22,27 @@ import { termOptionsPreview } from './constants';
import { EditProps } from './types';
import { getAllowedBlocks } from '../../utils/get-allowed-blocks';
import { EXCLUDED_BLOCKS } from '../../constants';
-import type { FilterOptionItem } from '../../types';
+import type { FilterOptionItem, FilterItemFields } from '../../types';
+import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
import { InitialDisabled } from '../../components/initial-disabled';
import { Notice } from '../../components/notice';
import { getTaxonomyLabel } from './utils';
import { sortFilterOptions } from '../../utils/sort-filter-options';
+type WPTaxonomyTerm = {
+ id: number;
+ name: string;
+ slug: string;
+ parent: number;
+ menu_order?: number;
+};
+
// Module-level stable references for the taxonomy-terms useSelect below.
// Avoids allocating fresh objects on every selector invocation, which would
// trip @wordpress/data's SCRIPT_DEBUG unstable-reference check. Frozen so an
// accidental mutation in a consumer cannot leak across renders or instances.
-const EMPTY_TAXONOMY_TERMS: ReadonlyArray< FilterOptionItem > = Object.freeze(
- [] as FilterOptionItem[]
+const EMPTY_TAXONOMY_TERMS: readonly WPTaxonomyTerm[] = Object.freeze(
+ [] as WPTaxonomyTerm[]
);
const EMPTY_TAXONOMY_TERMS_RESULT = Object.freeze( {
taxonomyTerms: EMPTY_TAXONOMY_TERMS,
@@ -47,12 +56,13 @@ function createHierarchicalList(
) {
const children = new Map();
- // First: categorize terms
+ // First: categorize terms by parent (numeric WP term ID)
terms.forEach( ( term ) => {
- if ( ! children.has( term.parent ) ) {
- children.set( term.parent, [] );
+ const parentId = term.parent ?? 0;
+ if ( ! children.has( parentId ) ) {
+ children.set( parentId, [] );
}
- children.get( term.parent ).push( term );
+ children.get( parentId ).push( term );
} );
// Next: sort them
@@ -74,12 +84,12 @@ function createHierarchicalList(
return;
}
termList.forEach( ( term ) => {
- if ( ! term.id || visited.has( term.id ) ) {
+ if ( ! term.termId || visited.has( term.termId ) ) {
return;
}
- visited.add( term.id );
+ visited.add( term.termId );
result.push( { ...term, depth } );
- const termChildren = children.get( term.id ) || [];
+ const termChildren = children.get( term.termId ) || [];
if ( termChildren.length > 0 ) {
addTermsRecursively( termChildren, depth + 1, visited );
}
@@ -129,8 +139,9 @@ const Edit = ( props: EditProps ) => {
};
return {
taxonomyTerms:
- getEntityRecords( 'taxonomy', taxonomy, selectArgs ) ||
- EMPTY_TAXONOMY_TERMS,
+ ( getEntityRecords( 'taxonomy', taxonomy, selectArgs ) as
+ | WPTaxonomyTerm[]
+ | null ) || EMPTY_TAXONOMY_TERMS,
isTermsLoading: ! hasFinishedResolution( 'getEntityRecords', [
'taxonomy',
taxonomy,
@@ -152,9 +163,15 @@ const Edit = ( props: EditProps ) => {
useEffect( () => {
if ( isPreview ) {
// In preview mode, use the preview data directly
- setTermOptions(
- sortFilterOptions( [ ...termOptionsPreview ], sortOrder )
- );
+ const previewItems = termOptionsPreview.map( ( item ) => {
+ if ( showCounts ) {
+ return item;
+ }
+ // Strip count when showCounts is false
+ const { count, ...rest } = item;
+ return rest;
+ } );
+ setTermOptions( sortFilterOptions( previewItems, sortOrder ) );
setIsOptionsLoading( false );
return;
}
@@ -193,8 +210,9 @@ const Edit = ( props: EditProps ) => {
label: term.name,
value: term.slug,
selected: false,
- count,
- id: term.id,
+ ...( showCounts && { count } ),
+ id: String( term.id ),
+ termId: term.id,
parent: term.parent || 0,
menuOrder: term.menu_order ?? 0,
} );
@@ -217,6 +235,7 @@ const Edit = ( props: EditProps ) => {
filteredCounts,
sortOrder,
hideEmpty,
+ showCounts,
isPreview,
isTermsLoading,
isFilterCountsLoading,
@@ -291,14 +310,15 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- filterData: {
+ woocommerceSelectableItems: {
items:
termOptions.length === 0 && isPreview
? termOptionsPreview
: termOptions,
+ selectionMode: 'multiple' as const,
+ storeNamespace: 'woocommerce/product-filters',
isLoading,
- showCounts,
- },
+ } satisfies SelectableItemsContext< FilterItemFields >,
} }
>
{ children }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
index e840b45d6d6..860c2b50bbb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/style.scss
@@ -178,3 +178,23 @@
.wc-block-product-filter--hidden {
display: none;
}
+
+.wc-block-product-filter__show-more {
+ background: none;
+ border: none;
+ font-family: inherit;
+ margin-top: 0.5em;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &:focus {
+ outline: 1px dotted currentColor;
+ outline-offset: 2px;
+ }
+}
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 c5f52d363ec..ebca526bfc7 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
@@ -2,48 +2,59 @@
* External dependencies
*/
import type { BlockEditProps } from '@wordpress/blocks';
-import type { ReactNode } from 'react';
-export type BlockAttributes = {
- productId?: string;
- isPreview: boolean;
-};
-
-export type EditProps = BlockEditProps< BlockAttributes >;
+/**
+ * Internal dependencies
+ */
+import type { SelectableItem } from '../../types/type-defs/selectable-items';
-export type FilterOptionItem = (
- | {
- label: string;
- ariaLabel?: string;
- }
- | {
- label: ReactNode;
- ariaLabel: string;
- }
- ) & {
- value: string;
- selected?: boolean;
- count: number;
- id?: number;
+// ----------------------------------------
+// Filter-specific item fields
+// ----------------------------------------
+export type FilterItemFields = {
+ count?: number;
+ termId?: number;
parent?: number;
depth?: number;
menuOrder?: number;
+ attributeQueryType?: 'and' | 'or';
+};
+
+export type FilterOptionItem = SelectableItem< FilterItemFields >;
+
+// ----------------------------------------
+// Parent store context + active-filter shape
+// ----------------------------------------
+export type ActiveFilterItem = {
+ type: string;
+ value: string;
+ attributeQueryType?: 'and' | 'or';
+ activeLabel: string;
+};
+
+export type ProductFiltersContext = {
+ isOverlayOpened: boolean;
+ params: Record< string, string >;
+ activeFilters: ActiveFilterItem[];
+ items?: FilterOptionItem[];
+ item: FilterOptionItem;
+ activeLabelTemplate: string;
+ filterType: string;
};
-export type FilterBlockContext = {
- filterData: {
- isLoading: boolean;
- items?: FilterOptionItem[];
- price?: {
- minPrice: number;
- minRange: number;
- maxPrice: number;
- maxRange: number;
- };
- showCounts?: boolean;
- };
+// ----------------------------------------
+// Block props
+// ----------------------------------------
+export type BlockAttributes = {
+ productId?: string;
+ isPreview: boolean;
};
+export type EditProps = BlockEditProps< BlockAttributes >;
+
+// ----------------------------------------
+// Editor color picker
+// ----------------------------------------
export type Color = {
slug?: string;
class?: string;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
index 927677eb301..b7c2d9eed8b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/utils/sort-filter-options.ts
@@ -52,8 +52,20 @@ export function sortFilterOptions(
getSortableText( a )
);
case 'count-asc':
+ // Fallback to name sort when count is not available
+ if ( a.count === undefined || b.count === undefined ) {
+ return getSortableText( a ).localeCompare(
+ getSortableText( b )
+ );
+ }
return a.count - b.count;
default: // count-desc
+ // Fallback to name sort when count is not available
+ if ( a.count === undefined || b.count === undefined ) {
+ return getSortableText( a ).localeCompare(
+ getSortableText( b )
+ );
+ }
return b.count - a.count;
}
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/range-input.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/range-input.ts
new file mode 100644
index 00000000000..6880691a1be
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/range-input.ts
@@ -0,0 +1,34 @@
+/**
+ * Internal dependencies
+ */
+import type { HTMLElementEvent } from './utils';
+
+export interface RangeInputContext {
+ min: number;
+ max: number;
+ currentMin: number;
+ currentMax: number;
+ step?: number;
+ storeNamespace: string;
+ isLoading?: boolean;
+}
+
+export type RangeInputBlockContext = {
+ woocommerceRangeInput: RangeInputContext;
+};
+
+/**
+ * Contract every parent store referenced by `storeNamespace` MUST satisfy.
+ * Use with `satisfies` to get compile-time enforcement:
+ *
+ * myRangeStore satisfies RangeInputParentStore;
+ *
+ * Inner blocks call `actions.setMin` / `actions.setMax` on input change.
+ * The parent derives its own semantics (currency, step, labels) from `context.min`/`max` etc.
+ */
+export interface RangeInputParentStore {
+ actions: {
+ setMin: ( event: HTMLElementEvent< HTMLInputElement > ) => void;
+ setMax: ( event: HTMLElementEvent< HTMLInputElement > ) => void;
+ };
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/removable-items.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/removable-items.ts
new file mode 100644
index 00000000000..680b093c1d7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/removable-items.ts
@@ -0,0 +1,34 @@
+export interface RemovableItem {
+ id: string;
+ type: string;
+ value: string;
+ label: string;
+}
+
+export interface RemovableItemsContext {
+ items: RemovableItem[];
+ storeNamespace: string;
+}
+
+export type RemovableItemsBlockContext = {
+ woocommerceRemovableItems: RemovableItemsContext;
+};
+
+/**
+ * Contract every parent store referenced by `storeNamespace` MUST satisfy.
+ * Use with `satisfies` to get compile-time enforcement:
+ *
+ * myStore satisfies RemovableItemsParentStore;
+ *
+ * `remove` removes a single item (reads `getContext().item`).
+ * `removeAll` clears every item.
+ */
+export interface RemovableItemsParentStore {
+ state: {
+ removableItems: readonly RemovableItem[];
+ };
+ actions: {
+ remove: () => void;
+ removeAll: () => void;
+ };
+}
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
new file mode 100644
index 00000000000..bd7140a662e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+export type SelectableItem< T = unknown > = (
+ | { label: string; ariaLabel?: string }
+ | { label: ReactNode; ariaLabel: string }
+ ) & {
+ id: string;
+ value: string;
+ selected?: boolean;
+ disabled?: boolean;
+ type?: string;
+} & T;
+
+export interface SelectableItemsContext< T = unknown > {
+ items: SelectableItem< T >[];
+ selectionMode: 'single' | 'multiple';
+ storeNamespace: string;
+ groupLabel?: string;
+ isLoading?: boolean;
+ /**
+ * Domain discriminator that inner blocks can use to vary presentation
+ * (e.g. `'rating'` unlocks star rendering in `checkbox-list`). Values
+ * are parent-defined strings; unknown values fall back to text.
+ */
+ filterType?: string;
+}
+
+export type SelectableItemsBlockContext< T = unknown > = {
+ woocommerceSelectableItems: SelectableItemsContext< T >;
+};
+
+/**
+ * Contract every parent store referenced by `storeNamespace` MUST satisfy.
+ *
+ * Two consumption patterns:
+ * - **Direct**: inner block iterates `state.selectableItems` under the parent
+ * namespace via nested `data-wp-interactive`; `data-wp-each` sets
+ * `context.item` automatically; `toggle()` reads it from context.
+ * - **Mirror**: inner block copies parent items into its own store, iterates
+ * under its own namespace, and calls `toggle( item )` explicitly.
+ *
+ * `toggle` accepts an optional item so both patterns work.
+ */
+export interface SelectableItemsParentStore< T = unknown > {
+ state: {
+ selectableItems: readonly SelectableItem< T >[];
+ };
+ actions: {
+ toggle: ( item?: SelectableItem< T > ) => void;
+ };
+}
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
new file mode 100644
index 00000000000..24bc99f617f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
@@ -0,0 +1,731 @@
+# 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.
+
+## Overview
+
+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 |
+| --- | --- | --- |
+| `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;
+```
+
+Missing method/getter → compile error. No runtime cost.
+
+---
+
+## Protocol: Selectable Items
+
+### Context Key
+
+```text
+woocommerceSelectableItems
+```
+
+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:
+
+```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.
+
+### 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:
+
+```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;
+```
+
+### 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
+
+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`
+
+```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;
+ type?: string;
+} & T;
+
+export interface SelectableItemsContext< T = unknown > {
+ items: SelectableItem< T >[];
+ selectionMode: 'single' | 'multiple';
+ storeNamespace: string;
+ groupLabel?: string;
+ isLoading?: boolean;
+ filterType?: string;
+}
+
+export type SelectableItemsBlockContext< T = unknown > = {
+ 'woocommerceSelectableItems': SelectableItemsContext< T >;
+};
+
+export interface SelectableItemsParentStore< T = unknown > {
+ state: {
+ selectableItems: readonly SelectableItem< T >[];
+ };
+ actions: {
+ toggle: ( item?: SelectableItem< T > ) => void;
+ };
+}
+```
+
+Filter blocks extend with `FilterItemFields` (from `product-filters/types.ts`):
+
+```typescript
+export type FilterItemFields = {
+ count: number;
+ termId?: number;
+ parent?: number;
+ depth?: number;
+ menuOrder?: number;
+};
+
+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
+ );
+ }
+}
+```
+
+---
+
+## Protocol: Removable Items
+
+Context key: `woocommerceRemovableItems`
+
+Used for lists of items that can be removed individually (active filter chips) with a "clear all" control.
+
+### Context Shape
+
+```typescript
+export interface RemovableItem {
+ type: string; // domain discriminator (e.g. "attribute/color", "price")
+ value: string;
+ label: string; // display text
+}
+
+export interface RemovableItemsContext {
+ items: RemovableItem[]; // SSR snapshot — parent's state.removableItems is SSOT post-hydration
+ storeNamespace: string;
+}
+```
+
+### Parent Store Requirements
+
+```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
+ };
+}
+```
+
+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
+
+Inner block (`removable-chips`):
+
+- 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
+
+Context key: `woocommerceRangeInput`
+
+Used for two-ended numeric range controls (price slider, generic range).
+
+### Context Shape
+
+```typescript
+export interface RangeInputContext {
+ min: number;
+ max: number;
+ currentMin: number;
+ currentMax: number;
+ step?: number;
+ storeNamespace: string;
+ isLoading?: boolean;
+}
+```
+
+### Parent Store Requirements
+
+```typescript
+export interface RangeInputParentStore {
+ 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`):
+
+- 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
+
+Reference implementation: `ProductFilterPriceSlider.php`, `inner-blocks/price-filter/frontend.ts`, `inner-blocks/price-slider/frontend.ts`.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cc86166d732..aa02c60038d 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -53502,7 +53502,7 @@ parameters:
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
- count: 2
+ count: 1
path: src/Blocks/BlockTypes/ProductFilterActive.php
-
@@ -53652,7 +53652,7 @@ parameters:
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
- count: 5
+ count: 2
path: src/Blocks/BlockTypes/ProductFilterChips.php
-
@@ -53748,13 +53748,7 @@ parameters:
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
- count: 3
- path: src/Blocks/BlockTypes/ProductFilterPriceSlider.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilterPriceSlider\:\:render\(\) should return string but empty return statement found\.$#'
- identifier: return.empty
- count: 1
+ count: 2
path: src/Blocks/BlockTypes/ProductFilterPriceSlider.php
-
@@ -53811,17 +53805,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductFilterRating.php
- -
- message: '#^Parameter \#1 \$text of function esc_attr expects string, int given\.$#'
- identifier: argument.type
- count: 2
- path: src/Blocks/BlockTypes/ProductFilterRating.php
-
- -
- message: '#^Parameter \#1 \$text of function esc_attr expects string, int\<0, max\> given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/BlockTypes/ProductFilterRating.php
-
message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(mixed\)\: bool\)\|null, Closure\(mixed\)\: \(0\|1\) given\.$#'
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
index 570bc96a159..bccb3fc60d0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
@@ -24,14 +24,46 @@ final class ProductFilterActive extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if ( ! isset( $block->context['activeFilters'] ) ) {
+ $active_filters = $block->context['activeFilters'] ?? array();
+
+ if ( ! is_array( $active_filters ) ) {
return $content;
}
- $active_filters = $block->context['activeFilters'];
+ $removable_items = array_values(
+ array_filter(
+ array_map(
+ function ( $item ) {
+ if ( ! is_array( $item ) ) {
+ return null;
+ }
+ $raw_type = $item['type'] ?? '';
+ $raw_value = $item['value'] ?? '';
+ $raw_label = $item['activeLabel'] ?? ( $item['label'] ?? '' );
+ if ( ! is_scalar( $raw_type ) || ! is_scalar( $raw_value ) || ! is_scalar( $raw_label ) ) {
+ return null;
+ }
+ $type = (string) $raw_type;
+ $value = (string) $raw_value;
+ $label = (string) $raw_label;
+ if ( '' === $type || '' === $value ) {
+ return null;
+ }
+ return array(
+ 'id' => $type . '_' . $value,
+ 'type' => $type,
+ 'value' => $value,
+ 'label' => $label,
+ );
+ },
+ $active_filters
+ )
+ )
+ );
$filter_context = array(
- 'items' => $active_filters,
+ 'items' => $removable_items,
+ 'storeNamespace' => 'woocommerce/product-filters',
);
$wrapper_attributes = array(
@@ -50,7 +82,7 @@ final class ProductFilterActive extends AbstractBlock {
wp_interactivity_state(
'woocommerce/product-filters',
array(
- 'hasActiveFilters' => ! empty( $active_filters ),
+ 'hasActiveFilters' => ! empty( $removable_items ),
),
);
@@ -68,7 +100,7 @@ final class ProductFilterActive extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceRemovableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index c11e4dcb6b5..1ab7c8284e7 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -185,31 +185,40 @@ final class ProductFilterAttribute extends AbstractBlock {
}
$filter_context = array(
- 'showCounts' => $block_attributes['showCounts'] ?? false,
- 'items' => array(),
- 'groupLabel' => $product_attribute->name,
+ 'items' => array(),
+ 'selectionMode' => $block_attributes['selectType'] ?? 'multiple',
+ 'storeNamespace' => 'woocommerce/product-filters',
+ 'groupLabel' => $product_attribute->name,
);
if ( ! empty( $attribute_counts ) ) {
+ $show_counts = $block_attributes['showCounts'] ?? false;
$attribute_options = array_map(
- function ( $term ) use ( $block_attributes, $attribute_counts, $selected_terms, $product_attribute ) {
+ function ( $term ) use ( $block_attributes, $attribute_counts, $selected_terms, $product_attribute, $show_counts ) {
$term = (array) $term;
$term['count'] = $attribute_counts[ $term['term_id'] ] ?? 0;
- return array(
+ $type = 'attribute/' . str_replace( 'pa_', '', $product_attribute->slug );
+ $item = array(
+ 'id' => $type . '-' . $term['slug'],
'label' => $term['name'],
'value' => $term['slug'],
'selected' => in_array( $term['slug'], $selected_terms, true ),
- 'count' => $term['count'],
- 'type' => 'attribute/' . str_replace( 'pa_', '', $product_attribute->slug ),
+ 'type' => $type,
'attributeQueryType' => $block_attributes['queryType'],
);
+
+ if ( $show_counts ) {
+ $item['count'] = $term['count'];
+ }
+
+ return $item;
},
$attribute_terms
);
- $filter_context['items'] = $attribute_options;
- }
+ $filter_context['items'] = array_values( $attribute_options );
+ }//end if
$wrapper_attributes = array(
'data-wp-interactive' => 'woocommerce/product-filters',
@@ -218,6 +227,7 @@ final class ProductFilterAttribute extends AbstractBlock {
array(
'activeLabelTemplate' => "$product_attribute->name: {{label}}",
'filterType' => 'attribute/' . str_replace( 'pa_', '', $product_attribute->slug ),
+ 'items' => $filter_context['items'],
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
),
@@ -234,7 +244,7 @@ final class ProductFilterAttribute extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
index f53071c51d1..f77a66cf8b9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
@@ -17,6 +17,13 @@ final class ProductFilterCheckboxList extends AbstractBlock {
*/
protected $block_name = 'product-filter-checkbox-list';
+ /**
+ * Default number of items to show before "Show more" button.
+ *
+ * @var int
+ */
+ const DISPLAY_LIMIT = 15;
+
/**
* Render the block.
*
@@ -26,15 +33,18 @@ final class ProductFilterCheckboxList extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if ( empty( $block->context['filterData'] ) ) {
+ if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
return '';
}
- $block_context = $block->context['filterData'];
- $items = $block_context['items'] ?? array();
- $show_counts = $block_context['showCounts'] ?? false;
- $classes = '';
- $style = '';
+ $block_context = $block->context['woocommerceSelectableItems'];
+ $items = is_array( $block_context['items'] ?? null ) ? $block_context['items'] : array();
+ $store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
+ $filter_type = $block_context['filterType'] ?? '';
+ $display_limit = self::DISPLAY_LIMIT;
+ $is_rating = 'rating' === $filter_type;
+ $classes = '';
+ $style = '';
$tags = new \WP_HTML_Tag_Processor( $content );
if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-checkbox-list' ) ) ) {
@@ -42,20 +52,16 @@ final class ProductFilterCheckboxList extends AbstractBlock {
$style = $tags->get_attribute( 'style' );
}
- $checked_items = array_filter(
- $items,
- function ( $item ) {
- return $item['selected'];
- }
- );
- $show_initially = 15;
- $remaining_initial_unchecked = count( $checked_items ) > $show_initially ? count( $checked_items ) : $show_initially - count( $checked_items );
- $count = 0;
-
$wrapper_attributes = array(
- 'data-wp-interactive' => 'woocommerce/product-filters',
- 'data-wp-key' => wp_unique_prefixed_id( $this->get_full_block_name() ),
- 'data-wp-context' => '{}',
+ 'data-wp-interactive' => 'woocommerce/product-filter-checkbox-list',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'storeNamespace' => $store_namespace,
+ 'displayLimit' => $display_limit,
+ 'isExpanded' => false,
+ ),
+ JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+ ),
'class' => esc_attr( $classes ),
);
@@ -64,6 +70,19 @@ final class ProductFilterCheckboxList extends AbstractBlock {
$wrapper_attributes['style'] = esc_attr( $style ) . ';';
}
+ $checkbox_svg = '<svg class="wc-block-product-filter-checkbox-list__mark" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.25 1.19922L3.75 6.69922L1 3.94922" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
+
+ $star_path = '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>';
+ $stars_svg = sprintf(
+ '<svg class="wc-block-product-filter-checkbox-list__stars-svg" width="120" height="24" viewBox="0 0 120 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">%1$s<g transform="translate(24, 0)">%1$s</g><g transform="translate(48, 0)">%1$s</g><g transform="translate(72, 0)">%1$s</g><g transform="translate(96, 0)">%1$s</g></svg>',
+ $star_path
+ );
+ $visible_items = array_slice( $items, 0, $display_limit, true );
+ $has_more_items = count( $items ) > count( $visible_items );
+ $hidden_count = max( 0, count( $items ) - count( $visible_items ) );
+ $first_item = reset( $items );
+ $show_counts = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+
ob_start();
?>
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
@@ -71,63 +90,130 @@ 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">
- <?php foreach ( $items as $item ) { ?>
- <?php $item_id = $item['type'] . '-' . $item['value']; ?>
+ <div
+ class="wc-block-product-filter-checkbox-list__items"
+ data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
+ >
+ <?php
+ foreach ( $visible_items as $index => $item ) :
+ $context_item = array_merge( $item, array( 'index' => $index ) );
+ ?>
<div
- data-wp-key="<?php echo esc_attr( $item_id ); ?>"
- class="wc-block-product-filter-checkbox-list__item <?php echo isset( $item['depth'] ) ? esc_attr( 'has-depth-' . $item['depth'] ) : ''; ?>"
- <?php if ( ! $item['selected'] ) : ?>
- <?php if ( $count >= $remaining_initial_unchecked ) : ?>
- data-wp-bind--hidden="!context.showAll"
- hidden
- <?php else : ?>
- <?php ++$count; ?>
- <?php endif; ?>
- <?php endif; ?>
+ 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"
>
<label
class="wc-block-product-filter-checkbox-list__label"
- for="<?php echo esc_attr( $item_id ); ?>"
+ for="<?php echo esc_attr( $item['id'] ); ?>"
>
<span class="wc-block-product-filter-checkbox-list__input-wrapper">
<input
- id="<?php echo esc_attr( $item_id ); ?>"
+ id="<?php echo esc_attr( $item['id'] ); ?>"
class="wc-block-product-filter-checkbox-list__input"
type="checkbox"
- aria-label="<?php echo esc_attr( $this->get_aria_label( $item, $show_counts ) ); ?>"
- data-wp-on--change="actions.toggleFilter"
+ <?php if ( ! empty( $item['ariaLabel'] ) ) : ?>
+ aria-label="<?php echo esc_attr( $item['ariaLabel'] ); ?>"
+ <?php endif; ?>
value="<?php echo esc_attr( $item['value'] ); ?>"
- data-wp-bind--checked="state.isFilterSelected"
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php checked( ! empty( $item['selected'] ) ); ?>
+ <?php disabled( ! empty( $item['disabled'] ) ); ?>
+ data-wp-bind--checked="context.item.selected"
+ data-wp-bind--disabled="context.item.disabled"
+ data-wp-on--change="actions.toggle"
>
- <svg class="wc-block-product-filter-checkbox-list__mark" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M9.25 1.19922L3.75 6.69922L1 3.94922" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- </svg>
+ <?php echo $checkbox_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</span>
<span class="wc-block-product-filter-checkbox-list__text-wrapper">
- <span class="wc-block-product-filter-checkbox-list__text">
- <?php echo $item['label']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- </span>
+ <?php if ( $is_rating ) : ?>
+ <?php $rating_style = sprintf( 'width: %s%%', ( (int) $item['value'] ) * 20 ); ?>
+ <span
+ class="wc-block-product-filter-checkbox-list__stars"
+ aria-label="<?php echo esc_attr( $item['ariaLabel'] ?? '' ); ?>"
+ style="<?php echo esc_attr( $rating_style ); ?>"
+ data-wp-bind--style="woocommerce/product-filter-checkbox-list::state.ratingStyle"
+ >
+ <?php echo $stars_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ </span>
+ <?php else : ?>
+ <span class="wc-block-product-filter-checkbox-list__text">
+ <?php echo esc_html( $item['label'] ); ?>
+ </span>
+ <?php endif; ?>
+ <?php if ( isset( $item['count'] ) ) : ?>
+ <span class="wc-block-product-filter-checkbox-list__count">
+ (<span data-wp-text="context.item.count"><?php echo esc_html( $item['count'] ); ?></span>)
+ </span>
+ <?php endif; ?>
+ </span>
+ </label>
+ </div>
+ <?php endforeach; ?>
+ <template
+ data-wp-each--item="state.selectableItems"
+ 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"
+ >
+ <label
+ class="wc-block-product-filter-checkbox-list__label"
+ data-wp-bind--for="context.item.id"
+ >
+ <span class="wc-block-product-filter-checkbox-list__input-wrapper">
+ <input
+ class="wc-block-product-filter-checkbox-list__input"
+ type="checkbox"
+ data-wp-bind--id="context.item.id"
+ data-wp-bind--aria-label="context.item.ariaLabel"
+ data-wp-bind--value="context.item.value"
+ data-wp-bind--checked="context.item.selected"
+ data-wp-bind--disabled="context.item.disabled"
+ data-wp-on--change="actions.toggle"
+ >
+ <?php echo $checkbox_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ </span>
+ <span class="wc-block-product-filter-checkbox-list__text-wrapper">
+ <?php if ( $is_rating ) : ?>
+ <span
+ class="wc-block-product-filter-checkbox-list__stars"
+ data-wp-bind--aria-label="context.item.ariaLabel"
+ data-wp-bind--style="woocommerce/product-filter-checkbox-list::state.ratingStyle"
+ >
+ <?php echo $stars_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ </span>
+ <?php else : ?>
+ <span
+ class="wc-block-product-filter-checkbox-list__text"
+ data-wp-text="context.item.label"
+ ></span>
+ <?php endif; ?>
<?php if ( $show_counts ) : ?>
<span class="wc-block-product-filter-checkbox-list__count">
- (<?php echo esc_html( $item['count'] ); ?>)
+ (<span data-wp-text="context.item.count"></span>)
</span>
<?php endif; ?>
</span>
</label>
</div>
- <?php } ?>
+ </template>
</div>
- <?php if ( count( $items ) > $show_initially ) : ?>
- <button
- class="wc-block-product-filter-checkbox-list__show-more"
- data-wp-bind--hidden="context.showAll"
- data-wp-on--click="actions.showAllListItems"
- hidden
- >
- <?php echo esc_html__( 'Show more…', 'woocommerce' ); ?>
- </button>
+ <?php if ( $has_more_items ) : ?>
+ <div class="wc-block-product-filter-checkbox-list__show-more">
+ <button
+ type="button"
+ class="wc-block-product-filter-checkbox-list__show-more-button"
+ data-wp-on--click="actions.showAll"
+ data-wp-bind--hidden="context.isExpanded"
+ >
+ <?php
+ /* translators: %d: number of hidden items */
+ echo esc_html( sprintf( __( 'Show %d more', 'woocommerce' ), $hidden_count ) );
+ ?>
+ </button>
+ </div>
<?php endif; ?>
</fieldset>
</div>
@@ -143,30 +229,4 @@ final class ProductFilterCheckboxList extends AbstractBlock {
protected function get_block_type_style() {
return null;
}
-
- /**
- * Get aria label for filter item.
- *
- * @param array $item Filter item.
- * @param bool $show_counts Whether to show counts.
- *
- * @return string Aria label.
- */
- private function get_aria_label( $item, $show_counts ) {
- if ( $show_counts ) {
- return sprintf(
- /* translators: %1$s: Product filter name, %2$d: Number of products */
- _n(
- '%1$s (%2$d product)',
- '%1$s (%2$d products)',
- $item['count'],
- 'woocommerce'
- ),
- $item['ariaLabel'] ?? $item['label'],
- $item['count']
- );
- }
-
- return $item['ariaLabel'] ?? $item['label'];
- }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index 83ad9164275..9b014decbf0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -17,6 +17,13 @@ final class ProductFilterChips extends AbstractBlock {
*/
protected $block_name = 'product-filter-chips';
+ /**
+ * Default number of items to show before "Show more" button.
+ *
+ * @var int
+ */
+ const DISPLAY_LIMIT = 15;
+
/**
* Render the block.
*
@@ -26,16 +33,16 @@ final class ProductFilterChips extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if (
- empty( $block->context['filterData'] )
- ) {
+ if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
return '';
}
- $items = $block->context['filterData']['items'] ?? array();
- $show_counts = $block->context['filterData']['showCounts'] ?? false;
- $classes = '';
- $style = '';
+ $block_context = $block->context['woocommerceSelectableItems'];
+ $items = is_array( $block_context['items'] ?? null ) ? $block_context['items'] : array();
+ $store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
+ $display_limit = self::DISPLAY_LIMIT;
+ $classes = '';
+ $style = '';
$tags = new \WP_HTML_Tag_Processor( $content );
if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-chips' ) ) ) {
@@ -43,20 +50,16 @@ final class ProductFilterChips extends AbstractBlock {
$style = $tags->get_attribute( 'style' );
}
- $checked_items = array_filter(
- $items,
- function ( $item ) {
- return $item['selected'];
- }
- );
- $show_initially = 15;
- $remaining_initial_unchecked = count( $checked_items ) > $show_initially ? count( $checked_items ) : $show_initially - count( $checked_items );
- $count = 0;
-
$wrapper_attributes = array(
- 'data-wp-interactive' => 'woocommerce/product-filters',
- 'data-wp-key' => wp_unique_prefixed_id( $this->get_full_block_name() ),
- 'data-wp-context' => '{}',
+ 'data-wp-interactive' => 'woocommerce/product-filter-chips',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'storeNamespace' => $store_namespace,
+ 'displayLimit' => $display_limit,
+ 'isExpanded' => false,
+ ),
+ JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+ ),
'class' => esc_attr( $classes ),
);
@@ -65,57 +68,98 @@ final class ProductFilterChips extends AbstractBlock {
$wrapper_attributes['style'] = esc_attr( $style ) . ';';
}
+ $visible_items = array_slice( $items, 0, $display_limit, true );
+ $has_more_items = count( $items ) > count( $visible_items );
+ $hidden_count = max( 0, count( $items ) - count( $visible_items ) );
+ $first_item = reset( $items );
+ $show_counts = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+
ob_start();
?>
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<fieldset>
- <?php if ( ! empty( $block->context['filterData']['groupLabel'] ) ) : ?>
- <legend class="screen-reader-text"><?php echo esc_html( $block->context['filterData']['groupLabel'] ); ?></legend>
+ <?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">
- <?php foreach ( $items as $item ) { ?>
- <?php $item_id = $item['type'] . '-' . $item['value']; ?>
+ <div
+ class="wc-block-product-filter-chips__items"
+ data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
+ >
+ <?php
+ foreach ( $visible_items as $index => $item ) :
+ $context_item = array_merge( $item, array( 'index' => $index ) );
+ ?>
<button
- data-wp-key="<?php echo esc_attr( $item_id ); ?>"
- id="<?php echo esc_attr( $item_id ); ?>"
class="wc-block-product-filter-chips__item"
type="button"
role="checkbox"
- aria-label="<?php echo esc_attr( $this->get_aria_label( $item, $show_counts ) ); ?>"
- data-wp-on--click="actions.toggleFilter"
- value="<?php echo esc_attr( $item['value'] ); ?>"
- data-wp-bind--aria-checked="state.isFilterSelected"
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- <?php if ( ! $item['selected'] ) : ?>
- <?php if ( $count >= $remaining_initial_unchecked ) : ?>
- data-wp-bind--hidden="!context.showAll"
- hidden
- <?php else : ?>
- <?php ++$count; ?>
- <?php endif; ?>
+ id="<?php echo esc_attr( $item['id'] ); ?>"
+ <?php if ( ! empty( $item['ariaLabel'] ) ) : ?>
+ aria-label="<?php echo esc_attr( $item['ariaLabel'] ); ?>"
<?php endif; ?>
+ value="<?php echo esc_attr( $item['value'] ); ?>"
+ 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 ?>
+ 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-on--click="actions.toggle"
>
<span class="wc-block-product-filter-chips__label">
<span class="wc-block-product-filter-chips__text">
- <?php echo $item['label']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo esc_html( $item['label'] ); ?>
</span>
+ <?php if ( isset( $item['count'] ) ) : ?>
+ <span class="wc-block-product-filter-chips__count">
+ (<span data-wp-text="context.item.count"><?php echo esc_html( $item['count'] ); ?></span>)
+ </span>
+ <?php endif; ?>
+ </span>
+ </button>
+ <?php endforeach; ?>
+ <template
+ data-wp-each--item="state.selectableItems"
+ data-wp-each-key="context.item.id"
+ >
+ <button
+ class="wc-block-product-filter-chips__item"
+ type="button"
+ role="checkbox"
+ data-wp-bind--id="context.item.id"
+ data-wp-bind--aria-label="context.item.ariaLabel"
+ 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-on--click="actions.toggle"
+ >
+ <span class="wc-block-product-filter-chips__label">
+ <span
+ class="wc-block-product-filter-chips__text"
+ data-wp-text="context.item.label"
+ ></span>
<?php if ( $show_counts ) : ?>
<span class="wc-block-product-filter-chips__count">
- (<?php echo esc_html( $item['count'] ); ?>)
+ (<span data-wp-text="context.item.count"></span>)
</span>
<?php endif; ?>
</span>
</button>
- <?php } ?>
+ </template>
</div>
- <?php if ( count( $items ) > $show_initially ) : ?>
+ <?php if ( $has_more_items ) : ?>
<button
+ type="button"
class="wc-block-product-filter-chips__show-more"
- data-wp-on--click="actions.showAllChips"
- data-wp-bind--hidden="context.showAll"
- hidden
+ data-wp-on--click="actions.showAll"
+ data-wp-bind--hidden="context.isExpanded"
>
- <?php echo esc_html__( 'Show more…', 'woocommerce' ); ?>
+ <?php
+ /* translators: %d: number of hidden items */
+ echo esc_html( sprintf( __( '+%d more', 'woocommerce' ), $hidden_count ) );
+ ?>
</button>
<?php endif; ?>
</fieldset>
@@ -123,30 +167,4 @@ final class ProductFilterChips extends AbstractBlock {
<?php
return ob_get_clean();
}
-
- /**
- * Get aria label for filter item.
- *
- * @param array $item Filter item.
- * @param bool $show_counts Whether to show counts.
- *
- * @return string Aria label.
- */
- private function get_aria_label( $item, $show_counts ) {
- if ( $show_counts ) {
- return sprintf(
- /* translators: %1$s: Product filter name, %2$d: Number of products */
- _n(
- '%1$s (%2$d product)',
- '%1$s (%2$d products)',
- $item['count'],
- 'woocommerce'
- ),
- $item['ariaLabel'] ?? $item['label'],
- $item['count']
- );
- }
-
- return $item['ariaLabel'] ?? $item['label'];
- }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
index ca6f6549950..d79c7171468 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
@@ -37,10 +37,12 @@ final class ProductFilterClearButton extends AbstractBlock {
*/
protected function render( $attributes, $content, $block ) {
// don't render if its admin, or ajax in progress.
+ $removable_context = $block->context['woocommerceRemovableItems'] ?? null;
if (
is_admin() ||
wp_doing_ajax() ||
- empty( $block->context['filterData'] )
+ empty( $removable_context ) ||
+ empty( $removable_context['items'] )
) {
return '';
}
@@ -48,7 +50,7 @@ final class ProductFilterClearButton extends AbstractBlock {
$p = new \WP_HTML_Tag_Processor( $content );
if ( $p->next_tag( array( 'class_name' => 'wp-block-button__link' ) ) ) {
- $p->set_attribute( 'data-wp-on--click', 'actions.removeAllActiveFilters' );
+ $p->set_attribute( 'data-wp-on--click', 'actions.removeAll' );
$content = $p->get_updated_html();
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
index 8826b96d4a7..5136b0b993b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
@@ -112,13 +112,10 @@ final class ProductFilterPrice extends AbstractBlock {
$formatted_max_price = html_entity_decode( wp_strip_all_tags( wc_price( $max_price, array( 'decimals' => 0 ) ) ) );
$filter_context = array(
- 'price' => array(
- 'minPrice' => $min_price,
- 'maxPrice' => $max_price,
- 'minRange' => $min_range,
- 'maxRange' => $max_range,
- ),
- 'groupLabel' => __( 'Price', 'woocommerce' ),
+ 'currentMin' => $min_price,
+ 'currentMax' => $max_price,
+ 'min' => $min_range,
+ 'max' => $max_range,
);
$wrapper_attributes = array(
@@ -181,7 +178,7 @@ final class ProductFilterPrice extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceRangeInput' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
index e7e014a0cac..f0a2c944f7b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
@@ -32,18 +32,18 @@ class ProductFilterPriceSlider extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if ( is_admin() || wp_doing_ajax() || empty( $block->context['filterData'] ) || empty( $block->context['filterData']['price'] ) ) {
+ if ( is_admin() || wp_doing_ajax() || empty( $block->context['woocommerceRangeInput'] ) ) {
return '';
}
- $price_data = $block->context['filterData']['price'];
- $min_price = $price_data['minPrice'];
- $max_price = $price_data['maxPrice'];
- $min_range = $price_data['minRange'];
- $max_range = $price_data['maxRange'];
+ $range_data = $block->context['woocommerceRangeInput'];
+ $min_range = $range_data['min'] ?? 0;
+ $max_range = $range_data['max'] ?? 0;
+ $min_price = $range_data['currentMin'] ?? $min_range;
+ $max_price = $range_data['currentMax'] ?? $max_range;
if ( $min_range === $max_range ) {
- return;
+ return '';
}
$classes = '';
@@ -126,7 +126,7 @@ class ProductFilterPriceSlider extends AbstractBlock {
min="<?php echo esc_attr( $min_range ); ?>"
max="<?php echo esc_attr( $max_range ); ?>"
data-wp-bind--value="state.minPrice"
- data-wp-on--input="actions.setMinPrice"
+ data-wp-on--input="actions.setMin"
data-wp-on--mouseup="actions.navigate"
data-wp-on--keyup="actions.navigate"
data-wp-on--touchend="actions.navigate"
@@ -138,7 +138,7 @@ class ProductFilterPriceSlider extends AbstractBlock {
min="<?php echo esc_attr( $min_range ); ?>"
max="<?php echo esc_attr( $max_range ); ?>"
data-wp-bind--value="state.maxPrice"
- data-wp-on--input="actions.setMaxPrice"
+ data-wp-on--input="actions.setMax"
data-wp-on--mouseup="actions.navigate"
data-wp-on--keyup="actions.navigate"
data-wp-on--touchend="actions.navigate"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
index c7c3d29aa62..c28a20ac1b7 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
@@ -101,30 +101,40 @@ final class ProductFilterRating extends AbstractBlock {
$rating_query = $filter_params[ self::RATING_FILTER_QUERY_VAR ] ?? '';
$selected_rating = array_filter( array_map( 'absint', explode( ',', $rating_query ) ) );
+ $show_counts = $attributes['showCounts'] ?? false;
$filter_options = array_map(
- function ( $rating ) use ( $selected_rating ) {
- $aria_label = sprintf(
+ function ( $rating ) use ( $selected_rating, $show_counts ) {
+ $rating_value = (int) $rating['rating'];
+ $aria_label = sprintf(
/* translators: %1$d is referring to rating value. Example: Rated 4 out of 5. */
__( 'Rated %1$d out of 5', 'woocommerce' ),
- $rating['rating'],
+ $rating_value,
);
- return array(
- 'label' => $this->render_rating_label( (int) $rating['rating'] ),
+ $item = array(
+ 'id' => 'rating-' . $rating_value,
+ 'label' => '',
'ariaLabel' => $aria_label,
- 'value' => (string) $rating['rating'],
- 'selected' => in_array( $rating['rating'], $selected_rating, true ),
- 'count' => $rating['count'],
+ 'value' => (string) $rating_value,
+ 'selected' => in_array( $rating_value, $selected_rating, true ),
'type' => 'rating',
);
+
+ if ( $show_counts ) {
+ $item['count'] = $rating['count'];
+ }
+
+ return $item;
},
$rating_counts_with_min
);
$filter_context = array(
- 'items' => $filter_options,
- 'showCounts' => $attributes['showCounts'] ?? false,
- 'groupLabel' => __( 'Rating', 'woocommerce' ),
+ 'items' => array_values( $filter_options ),
+ 'selectionMode' => 'multiple',
+ 'storeNamespace' => 'woocommerce/product-filters',
+ 'groupLabel' => __( 'Rating', 'woocommerce' ),
+ 'filterType' => 'rating',
);
$wrapper_attributes = array(
@@ -135,6 +145,7 @@ final class ProductFilterRating extends AbstractBlock {
/* translators: {{label}} is the rating filter item label. */
'activeLabelTemplate' => __( 'Rating: {{label}}', 'woocommerce' ),
'filterType' => 'rating',
+ 'items' => array_values( $filter_options ),
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
),
@@ -151,7 +162,7 @@ final class ProductFilterRating extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
@@ -159,45 +170,6 @@ final class ProductFilterRating extends AbstractBlock {
);
}
- /**
- * Render the rating label.
- *
- * @param int $rating The rating to render.
- * @return string|false
- */
- private function render_rating_label( $rating ) {
- $view_box_width = $rating * 24;
-
- $rating_label = sprintf(
- /* translators: %1$d is referring to rating value. Example: Rated 4 out of 5. */
- __( 'Rated %1$d out of 5', 'woocommerce' ),
- $rating,
- );
-
- ob_start();
- ?>
- <svg
- width="<?php echo esc_attr( $view_box_width ); ?>"
- height="24"
- viewBox="0 0 <?php echo esc_attr( $view_box_width ); ?> 24"
- fill="currentColor"
- aria-label="<?php echo esc_attr( $rating_label ); ?>"
- >
- <?php
- for ( $i = 0; $i < $rating; $i++ ) {
- ?>
- <path
- d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
- transform="translate(<?php echo esc_attr( $i * 24 ); ?>, 0)"
- />
- <?php
- }
- ?>
- </svg>
- <?php
- return ob_get_clean();
- }
-
/**
* Retrieve the rating filter data for current block.
*
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php
index 0e9798a720f..d27174ddf9c 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php
@@ -27,12 +27,12 @@ final class ProductFilterRemovableChips extends AbstractBlock {
*/
protected function render( $attributes, $content, $block ) {
if (
- empty( $block->context['filterData'] )
+ empty( $block->context['woocommerceRemovableItems'] )
) {
return '';
}
- $filter_items = $block->context['filterData']['items'] ?? array();
+ $filter_items = $block->context['woocommerceRemovableItems']['items'] ?? array();
$style = '';
@@ -55,35 +55,36 @@ final class ProductFilterRemovableChips extends AbstractBlock {
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<ul class="wc-block-product-filter-removable-chips__items">
<template
- data-wp-each="state.activeFilters"
- data-wp-each-key="context.item.uid"
+ data-wp-each--item="state.removableItems"
+ data-wp-each-key="context.item.id"
>
<li class="wc-block-product-filter-removable-chips__item">
- <span class="wc-block-product-filter-removable-chips__label" data-wp-text="context.item.activeLabel"></span>
+ <span class="wc-block-product-filter-removable-chips__label" data-wp-text="context.item.label"></span>
<button
type="button"
class="wc-block-product-filter-removable-chips__remove"
- data-wp-bind--aria-label="state.removeActiveFilterLabel"
- data-wp-on--click="actions.removeActiveFilter"
+ data-wp-bind--aria-label="state.removeItemLabel"
+ data-wp-on--click="actions.remove"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="25" height="25" class="wc-block-product-filter-removable-chips__remove-icon" aria-hidden="true" focusable="false"><path d="M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"></path></svg>
- <span class="screen-reader-text" data-wp-text="state.removeActiveFilterLabel"></span>
+ <span class="screen-reader-text" data-wp-text="state.removeItemLabel"></span>
</button>
</li>
</template>
<?php foreach ( $filter_items as $item ) : ?>
<?php // translators: %s: item label. ?>
- <?php $remove_label = sprintf( __( 'Remove filter: %s', 'woocommerce' ), $item['activeLabel'] ); ?>
- <li class="wc-block-product-filter-removable-chips__item" data-wp-each-child>
+ <?php $remove_label = sprintf( __( 'Remove filter: %s', 'woocommerce' ), $item['label'] ); ?>
+ <li class="wc-block-product-filter-removable-chips__item" data-wp-each-child
+ <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ >
<span class="wc-block-product-filter-removable-chips__label">
- <?php echo esc_html( $item['activeLabel'] ); ?>
+ <?php echo esc_html( $item['label'] ); ?>
</span>
<button
type="button"
class="wc-block-product-filter-removable-chips__remove"
aria-label="<?php echo esc_attr( $remove_label ); ?>"
- data-wp-on--click="actions.removeActiveFilter"
- <?php echo wp_interactivity_data_wp_context( array( 'item' => $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ data-wp-on--click="actions.remove"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="25" height="25" class="wc-block-product-filter-removable-chips__remove-icon" aria-hidden="true" focusable="false"><path d="M12 13.06l3.712 3.713 1.061-1.06L13.061 12l3.712-3.712-1.06-1.06L12 10.938 8.288 7.227l-1.061 1.06L10.939 12l-3.712 3.712 1.06 1.061L12 13.061z"></path></svg>
<span class="screen-reader-text"><?php echo esc_html( $remove_label ); ?></span>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
index 4a6e31887a2..142dac83021 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
@@ -108,23 +108,31 @@ final class ProductFilterStatus extends AbstractBlock {
$query = $filter_params[ self::STOCK_STATUS_QUERY_VAR ] ?? '';
$selected_stock_statuses = array_filter( explode( ',', $query ) );
+ $show_counts = $attributes['showCounts'] ?? false;
$filter_options = array_map(
- function ( $item ) use ( $stock_statuses, $selected_stock_statuses ) {
- return array(
+ function ( $item ) use ( $stock_statuses, $selected_stock_statuses, $show_counts ) {
+ $option = array(
+ 'id' => 'status-' . $item['status'],
'label' => $stock_statuses[ $item['status'] ],
'value' => $item['status'],
'selected' => in_array( $item['status'], $selected_stock_statuses, true ),
- 'count' => $item['count'],
'type' => 'status',
);
+
+ if ( $show_counts ) {
+ $option['count'] = $item['count'];
+ }
+
+ return $option;
},
$stock_status_data
);
$filter_context = array(
- 'items' => array_values( $filter_options ),
- 'showCounts' => $attributes['showCounts'] ?? false,
- 'groupLabel' => __( 'Status', 'woocommerce' ),
+ 'items' => array_values( $filter_options ),
+ 'selectionMode' => 'multiple',
+ 'storeNamespace' => 'woocommerce/product-filters',
+ 'groupLabel' => __( 'Status', 'woocommerce' ),
);
$wrapper_attributes = array(
@@ -135,6 +143,7 @@ final class ProductFilterStatus extends AbstractBlock {
/* translators: {{label}} is the status filter item label. */
'activeLabelTemplate' => __( 'Status: {{label}}', 'woocommerce' ),
'filterType' => 'status',
+ 'items' => $filter_context['items'],
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
),
@@ -153,7 +162,7 @@ final class ProductFilterStatus extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index c7d40bcc14b..41d7ad5dca6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -44,7 +44,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$term_slugs = array_map( 'sanitize_title', explode( ',', $params[ $param_key ] ) );
$active_taxonomies[ $taxonomy_slug ] = $term_slugs;
$all_term_slugs = array_merge( $all_term_slugs, $term_slugs );
- }
+ }//end if
}
if ( empty( $active_taxonomies ) ) {
@@ -72,7 +72,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
'value' => $term->slug,
'activeLabel' => $taxonomy_object->labels->singular_name . ': ' . $term->name,
);
- }
+ }//end if
}
return $items;
@@ -212,9 +212,10 @@ final class ProductFilterTaxonomy extends AbstractBlock {
);
$filter_context = array(
- 'showCounts' => $block_attributes['showCounts'] ?? false,
- 'items' => array(),
- 'groupLabel' => $taxonomy_object->labels->singular_name,
+ 'items' => array(),
+ 'selectionMode' => 'multiple',
+ 'storeNamespace' => 'woocommerce/product-filters',
+ 'groupLabel' => $taxonomy_object->labels->singular_name,
);
$taxonomy_counts = $this->get_taxonomy_term_counts( $block, $taxonomy );
@@ -237,21 +238,27 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$selected_terms = array_filter( array_map( 'sanitize_title', explode( ',', $filter_params[ $param_key ] ) ) );
}
+ $show_counts = $block_attributes['showCounts'] ?? false;
$taxonomy_options = array_map(
- function ( $term ) use ( $taxonomy_counts, $selected_terms, $taxonomy ) {
+ function ( $term ) use ( $taxonomy_counts, $selected_terms, $taxonomy, $show_counts ) {
$term = (array) $term;
$term['count'] = $taxonomy_counts[ $term['term_id'] ] ?? 0;
+ $type = 'taxonomy/' . $taxonomy;
$option = array(
+ 'id' => $type . '-' . $term['slug'],
'label' => $term['name'],
'value' => $term['slug'],
'selected' => in_array( $term['slug'], $selected_terms, true ),
- 'count' => $term['count'],
- 'type' => 'taxonomy/' . $taxonomy,
+ 'type' => $type,
);
+ if ( $show_counts ) {
+ $option['count'] = $term['count'];
+ }
+
if ( is_taxonomy_hierarchical( $taxonomy ) ) {
- $option['id'] = $term['term_id'];
+ $option['termId'] = $term['term_id'];
if ( isset( $term['depth'] ) && $term['depth'] > 0 ) {
$option['depth'] = $term['depth'];
@@ -260,13 +267,14 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$option['parent'] = $term['parent'];
}
}
+
return $option;
},
$taxonomy_terms
);
- $filter_context['items'] = $taxonomy_options;
- }
+ $filter_context['items'] = array_values( $taxonomy_options );
+ }//end if
$wrapper_attributes = array(
'data-wp-interactive' => 'woocommerce/product-filters',
@@ -275,6 +283,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
array(
'activeLabelTemplate' => $taxonomy_object->labels->singular_name . ': {{label}}',
'filterType' => 'taxonomy/' . $taxonomy,
+ 'items' => $filter_context['items'],
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
),
@@ -291,7 +300,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
array_reduce(
$block->parsed_block['innerBlocks'],
function ( $carry, $parsed_block ) use ( $filter_context ) {
- $carry .= ( new \WP_Block( $parsed_block, array( 'filterData' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
@@ -342,7 +351,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
}
return $this->sort_terms_by_criteria( $terms, $orderby, $order, $taxonomy_counts );
- }
+ }//end if
return $this->get_hierarchical_terms( $taxonomy, $taxonomy_counts, $hide_empty, $orderby, $order );
}
@@ -435,8 +444,8 @@ final class ProductFilterTaxonomy extends AbstractBlock {
foreach ( $terms as $term ) {
if ( ! empty( $term['children'] ) ) {
$term['children'] = $this->sort_terms_by_criteria( $term['children'], $orderby, $order, $taxonomy_counts );
- }
- }
+ }//end if
+ }//end foreach
$sorted = $this->sort_terms_by_criteria( $terms, $orderby, $order, $taxonomy_counts );
return $sorted;
}
@@ -485,7 +494,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
$this->flatten_terms_list( $term['children'], $result, $visited_ids, $depth + 1 );
unset( $result[ $term_id ]['children'] );
}
- }
+ }//end foreach
}
/**
@@ -558,7 +567,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
default:
$comparison = strcasecmp( $a->name, $b->name );
break;
- }
+ }//end switch
return $comparison * $sort_order;
}