Commit db8889cbf95 for woocommerce
commit db8889cbf9536010396e7f4a6ebdc02bb8565e75
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Thu May 21 12:29:52 2026 +0200
Use the same inner blocks from Product Filters in Add to Cart + Options: Variation Selector (#64887)
* Update outdated comment
* Add changelog
* Introduce Product Filters Dropdown inner block
* PHPStan
* Remove unnecessary navigation
* Make sure selectableItems is an array before iterating through it
* Update comment about supported attribute types
* Make sure aria-label always exists
* Rename block_context to selectable_items
* Make sure we don't print empty options
* Add back navigate
* Small nits
* Add tests
* Unify labels
* Update Add to Cart + Options to use same inner blocks as Product Filters
* Improve styles
* Add changelog
* Set different display limit for Product Filters and Add to Cart + Options
* Linting
* Remove no longer used test
* Undo unnecessary change
* Clean up
* Make it so Dropdown inner block can only be added inside the Add to Cart + Options block
* Further improvements
* Fix Dropdown block not being visible when loading the editor
* Typo
* Get rid of the Variation Selector: Attribute Options block
* Prevent Varition Selector: Attribute Name from appearing in the list of styles
* Reduce tests flakiness
* Remove changes moved to a separate PR
* Clean up
* Clean up 2
* Remove unnecessary changelog file
* Clean up 3
* Clean up 4
* Fix wrong role in template chips
* Guard variationAttributeOptions before iterating through it
* Document 'hidden' prop
* Remove role='button' from AttributeItem inactive rows
* Simplify types
* Add extra guards
* Improve guards
* Improve storeNamespace guard
* Remove unnecessary item
* Linting
* Simplify code
* Remove dependency on FilterItemFields
* Optimize EXCLUDED_BLOCKS list
* Make Dropdown a top-level block
* Make sure selected values are taken into account during SSR
* Make sure elements have the correct labels and ids
* Strengthen PHP checks
* Fix wrong labels for Chips block
* Fix PHPStan notices
* Improve types
* Add backwards compatibility to new Variation Selector inner blocks (#65144)
* Add backwards compatibility to new Variation Selector inner blocks
* Add changelog
* Fix wrapper blocks disappearing on migration
* Migrate all block attributes
* Migrate all block attributes
* Update tests so we use the real optionStyle value
* Add testdox to PHP tests
* Unify prefixes of context keys in Inner Blocks Protocol (#65153)
* Unify prefixes of context keys in Inner Blocks Protocol
* Add testdox to PHP tests
* Linting
* Move logic to init chips colors to the Chips inner block (#65200)
* Move logic to init chips colors to the Chips inner block
* Add changelog
* Linting
* reuse exisiting variables, generate minimal colors, better fallback chain
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
diff --git a/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks b/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks
new file mode 100644
index 00000000000..d0b1ba8cbb7
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add to Cart + Options: Variation Selector block now can use the same inner blocks as Product Filters
diff --git a/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks-backwards-compatibility b/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks-backwards-compatibility
new file mode 100644
index 00000000000..d1e9aa72319
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-add-to-cart-with-options-filter-inner-blocks-backwards-compatibility
@@ -0,0 +1,5 @@
+Significance: patch
+Type: update
+Comment: Add backwards compatibility to new Variation Selector inner blocks
+
+
diff --git a/plugins/woocommerce/changelog/update-inner-blocks-protocol-unify-context-prefix b/plugins/woocommerce/changelog/update-inner-blocks-protocol-unify-context-prefix
new file mode 100644
index 00000000000..79c44e7f382
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-inner-blocks-protocol-unify-context-prefix
@@ -0,0 +1,5 @@
+Significance: patch
+Type: update
+Comment: Unify prefixes of context keys in Inner Blocks Protocol
+
+
diff --git a/plugins/woocommerce/changelog/update-move-init-colors-to-chips-block b/plugins/woocommerce/changelog/update-move-init-colors-to-chips-block
new file mode 100644
index 00000000000..8b4b1aa790c
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-move-init-colors-to-chips-block
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Move logic to init chips colors to the Chips inner block
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/block.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/block.ts
index 643e6978f6f..47ccd16ac45 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/block.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/block.ts
@@ -30,7 +30,6 @@ import '../grouped-product-selector/product-item-selector';
import '../variation-selector';
import '../variation-selector/attribute';
import '../variation-selector/attribute-name';
-import '../variation-selector/attribute-options';
import '../variation-description';
const mockTemplatePartsHTML: Record< string, string > = {
@@ -325,7 +324,6 @@ describe( 'Add to Cart + Options block', () => {
await expectHasBlock( 'Variation Selector (Beta)' );
await expectHasBlock( 'Variation Selector: Attribute Name (Beta)' );
- await expectHasBlock( 'Variation Selector: Attribute Options (Beta)' );
await expectHasBlock( 'Variation Selector: Template (Beta)' );
await expectHasBlock( 'Variation Description (Beta)' );
await expectHasBlock( 'Product Stock Indicator' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/block.json
deleted file mode 100644
index d745d5632bd..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/block.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "name": "woocommerce/add-to-cart-with-options-variation-selector-attribute-options",
- "title": "Variation Selector: Attribute Options (Beta)",
- "description": "Display the attribute options associated with a variable product.",
- "category": "woocommerce-product-elements",
- "keywords": [ "WooCommerce" ],
- "ancestor": [
- "woocommerce/add-to-cart-with-options-variation-selector-attribute"
- ],
- "attributes": {
- "optionStyle": {
- "type": "string",
- "enum": [ "pills", "dropdown" ],
- "default": "pills"
- },
- "autoselect": {
- "type": "boolean",
- "default": false
- },
- "disabledAttributesAction": {
- "type": "string",
- "enum": [ "disable", "hide" ],
- "default": "disable"
- }
- },
- "textdomain": "woocommerce",
- "apiVersion": 3,
- "$schema": "https://schemas.wp.org/trunk/block.json",
- "supports": {
- "inserter": false,
- "interactivity": true
- },
- "usesContext": [
- "woocommerce/attributeId",
- "woocommerce/attributeName",
- "woocommerce/attributeTerms"
- ],
- "style": "file:../woocommerce/add-to-cart-with-options-variation-selector-attribute-options-style.css"
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/edit.tsx
deleted file mode 100644
index 3b4f08f70d8..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/edit.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-/**
- * External dependencies
- */
-import clsx from 'clsx';
-import { useCustomDataContext } from '@woocommerce/shared-context';
-import type { ProductResponseAttributeItem } from '@woocommerce/types';
-import { __ } from '@wordpress/i18n';
-import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
-import { type BlockEditProps } from '@wordpress/blocks';
-import {
- Disabled,
- SelectControl,
- ToggleControl,
- __experimentalToggleGroupControl as ToggleGroupControl,
- __experimentalToggleGroupControlOption as ToggleGroupControlOption,
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToolsPanel as ToolsPanel,
- // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
- __experimentalToolsPanelItem as ToolsPanelItem,
-} from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import { useThemeColors } from '../../../../shared/hooks/use-theme-colors';
-
-interface Attributes {
- className?: string;
- optionStyle: 'pills' | 'dropdown';
- autoselect: boolean;
- disabledAttributesAction: 'disable' | 'hide';
-}
-
-function Pills( {
- id,
- options,
-}: {
- id: string;
- options: SelectControl.Option[];
-} ) {
- return (
- <ul
- id={ id }
- className="wc-block-add-to-cart-with-options-variation-selector-attribute-options__pills"
- >
- { options.map( ( option, index ) => (
- <li
- key={ option.value }
- className={ clsx(
- 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill',
- {
- 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill--selected':
- index === 0,
- 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill--disabled':
- option.disabled,
- }
- ) }
- >
- { option.label }
- </li>
- ) ) }
- </ul>
- );
-}
-
-export default function AttributeOptionsEdit(
- props: BlockEditProps< Attributes >
-) {
- const { attributes, setAttributes } = props;
- const { className, optionStyle, autoselect, disabledAttributesAction } =
- attributes;
-
- const blockProps = useBlockProps( {
- className,
- } );
-
- // Apply selected variation pill styles based on Site Editor's background and text colors.
- useThemeColors(
- 'add-to-cart-with-options-variation-selector-attribute-options',
- ( { editorBackgroundColor, editorColor } ) => `
- :where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill--selected) {
- --pill-color: ${ editorBackgroundColor };
- --pill-background-color: ${ editorColor };
- }
- `
- );
-
- const { data: attribute } =
- useCustomDataContext< ProductResponseAttributeItem >( 'attribute' );
-
- if ( ! attribute ) return null;
-
- const options = attribute.terms.map( ( term, index ) => ( {
- value: term.slug,
- label: term.name,
- disabled: index > 1 && index === attribute.terms.length - 1,
- } ) );
-
- return (
- <div { ...blockProps }>
- <InspectorControls>
- <ToolsPanel
- label={ __( 'Style', 'woocommerce' ) }
- resetAll={ () => setAttributes( { optionStyle: 'pills' } ) }
- >
- <ToolsPanelItem
- hasValue={ () => optionStyle !== 'pills' }
- label={ __( 'Style', 'woocommerce' ) }
- onDeselect={ () =>
- setAttributes( { optionStyle: 'pills' } )
- }
- isShownByDefault
- >
- <ToggleGroupControl
- label={ __( 'Style', 'woocommerce' ) }
- value={ optionStyle }
- onChange={ ( newOptionStyle ) => {
- if (
- newOptionStyle === 'pills' ||
- newOptionStyle === 'dropdown'
- ) {
- setAttributes( {
- optionStyle: newOptionStyle,
- } );
- }
- } }
- isBlock
- hideLabelFromVision
- size="__unstable-large"
- >
- <ToggleGroupControlOption
- value="pills"
- label={ __( 'Pills', 'woocommerce' ) }
- />
- <ToggleGroupControlOption
- value="dropdown"
- label={ __( 'Dropdown', 'woocommerce' ) }
- />
- </ToggleGroupControl>
- </ToolsPanelItem>
- </ToolsPanel>
- <ToolsPanel
- label={ __( 'Auto-select', 'woocommerce' ) }
- resetAll={ () =>
- setAttributes( {
- autoselect: false,
- disabledAttributesAction: 'disable',
- } )
- }
- >
- <ToolsPanelItem
- label={ __(
- 'Auto-select when only one option is available',
- 'woocommerce'
- ) }
- hasValue={ () => autoselect }
- onDeselect={ () =>
- setAttributes( { autoselect: false } )
- }
- isShownByDefault
- >
- <ToggleControl
- label={ __(
- 'Auto-select when only one option is available',
- 'woocommerce'
- ) }
- help={ __(
- 'Automatically select options on page load or after the shopper changes attributes, when only one valid choice is available.',
- 'woocommerce'
- ) }
- checked={ autoselect }
- onChange={ () =>
- setAttributes( { autoselect: ! autoselect } )
- }
- __nextHasNoMarginBottom
- />
- </ToolsPanelItem>
- <ToolsPanelItem
- label={ __( 'Invalid options', 'woocommerce' ) }
- hasValue={ () =>
- disabledAttributesAction !== 'disable'
- }
- onDeselect={ () =>
- setAttributes( {
- disabledAttributesAction: 'disable',
- } )
- }
- isShownByDefault
- >
- <ToggleGroupControl
- label={ __( 'Invalid options', 'woocommerce' ) }
- help={ __(
- 'Control the display of invalid options.',
- 'woocommerce'
- ) }
- value={ disabledAttributesAction }
- onChange={ ( value ) => {
- if ( value === 'hide' || value === 'disable' ) {
- setAttributes( {
- disabledAttributesAction: value,
- } );
- }
- } }
- isBlock
- size="__unstable-large"
- >
- <ToggleGroupControlOption
- value="disable"
- label={ __( 'Grayed-out', 'woocommerce' ) }
- />
- <ToggleGroupControlOption
- value="hide"
- label={ __( 'Hidden', 'woocommerce' ) }
- />
- </ToggleGroupControl>
- </ToolsPanelItem>
- </ToolsPanel>
- </InspectorControls>
-
- <Disabled>
- { optionStyle === 'dropdown' ? (
- <select
- id={ attribute.taxonomy }
- className="wc-block-add-to-cart-with-options-variation-selector-attribute-options__dropdown"
- >
- { options.map( ( option ) => (
- <option key={ option.value } value={ option.value }>
- { option.label }
- </option>
- ) ) }
- </select>
- ) : (
- <Pills id={ attribute.taxonomy } options={ options } />
- ) }
- </Disabled>
- </div>
- );
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/index.tsx
deleted file mode 100644
index 0c1f7356a95..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/index.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * External dependencies
- */
-import { registerBlockType } from '@wordpress/blocks';
-import { Icon, buttons } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import metadata from './block.json';
-import AttributeOptionsEdit from './edit';
-import './style.scss';
-
-registerBlockType( metadata, {
- edit: AttributeOptionsEdit,
- attributes: metadata.attributes,
- icon: {
- src: <Icon icon={ buttons } />,
- },
- save: () => null,
-} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/style.scss
deleted file mode 100644
index 0250b5a71de..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute-options/style.scss
+++ /dev/null
@@ -1,72 +0,0 @@
-:where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pills) {
- display: inline-flex;
- gap: $grid-unit-10;
- list-style: none;
- padding: 0;
- margin: 0;
- flex-wrap: wrap;
-}
-
-:where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill) {
- --pill-color: currentColor;
- --pill-background-color: transparent;
- background-color: var(--pill-background-color);
- border-radius: 2px;
- border: 1px solid color-mix(in srgb, var(--pill-color) 40%, transparent);
- color: var(--pill-color);
- cursor: pointer;
- font-size: 0.8em;
- padding: 0.25em 0.75em;
- user-select: none;
-
- &:where(:has(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input:disabled)) {
- color: color-mix(in srgb, var(--pill-color) 40%, transparent);
- cursor: not-allowed;
- text-decoration: line-through;
- }
-
- &:where(:hover:not(:has(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input:disabled))) {
- background-color: color-mix(
- in srgb,
- var(--pill-color) 10%,
- transparent
- );
- }
-
- &:where(:hover:has(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input:checked)) {
- background-color: color-mix(
- in srgb,
- var(--pill-background-color) 85%,
- transparent
- );
- }
-
- /* When the pill button is hidden, we also need to hide the label (&) */
- &:has(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input[hidden]) {
- display: none;
- }
-
- &:where(:focus-within) {
- outline: 1px solid var(--pill-color);
- outline-offset: 1px;
- }
-
- &:where(:focus-within:has(
- .wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input:checked
- )) {
- outline-color: var(--pill-background-color);
- }
-
- // We can't make it display: none, otherwise it's not focusable.
- :where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input) {
- height: 0;
- width: 0;
- opacity: 0;
- position: absolute;
- }
-}
-
-:where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__dropdown) {
- height: 3em;
- padding: 0.25em 1em;
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/block.json
index 910b2b1fc16..21c9ac561f0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/block.json
@@ -6,11 +6,27 @@
"keywords": [ "WooCommerce" ],
"usesContext": [ "postId" ],
"ancestor": [ "woocommerce/add-to-cart-with-options-variation-selector" ],
+ "attributes": {
+ "displayStyle": {
+ "type": "string",
+ "default": "woocommerce/product-filter-chips"
+ },
+ "autoselect": {
+ "type": "boolean",
+ "default": false
+ },
+ "disabledAttributesAction": {
+ "type": "string",
+ "enum": [ "disable", "hide" ],
+ "default": "disable"
+ }
+ },
"textdomain": "woocommerce",
"apiVersion": 3,
"$schema": "https://schemas.wp.org/trunk/block.json",
"supports": {
"inserter": false,
"interactivity": true
- }
+ },
+ "style": "file:../woocommerce/add-to-cart-with-options-variation-selector-attribute-style.css"
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
index add385f823a..c643193027a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
@@ -34,9 +34,7 @@ export const ATTRIBUTE_ITEM_TEMPLATE: TemplateArray = [
fontSize: 'medium',
},
],
- [
- 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options',
- ],
+ [ 'woocommerce/product-filter-chips' ],
],
],
],
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/deprecated.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/deprecated.tsx
new file mode 100644
index 00000000000..ccd48164d15
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/deprecated.tsx
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import { BlockInstance, createBlock } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import save from './save';
+
+const LEGACY_ATTRIBUTE_OPTIONS_BLOCK =
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options';
+const INNER_CHIPS = 'woocommerce/product-filter-chips';
+const INNER_DROPDOWN = 'woocommerce/dropdown';
+
+interface MigratedAttributeSettings {
+ displayStyle: string;
+ autoselect: boolean;
+ disabledAttributesAction: 'disable' | 'hide';
+}
+
+function containsLegacyAttributeOptionsBlock(
+ blocks: BlockInstance[]
+): boolean {
+ return blocks.some( ( block ) => {
+ if (
+ block.name === 'core/missing' &&
+ block.attributes.originalName === LEGACY_ATTRIBUTE_OPTIONS_BLOCK
+ ) {
+ return true;
+ }
+
+ if ( block.innerBlocks?.length ) {
+ return containsLegacyAttributeOptionsBlock( block.innerBlocks );
+ }
+
+ return false;
+ } );
+}
+
+function migrateInnerBlocks(
+ innerBlocks: BlockInstance[],
+ settings: MigratedAttributeSettings
+): BlockInstance[] {
+ return innerBlocks.flatMap( ( block ) => {
+ if (
+ block.name === 'core/missing' &&
+ block.attributes.originalName === LEGACY_ATTRIBUTE_OPTIONS_BLOCK
+ ) {
+ if ( block.originalContent?.includes( '"autoselect":true' ) ) {
+ settings.autoselect = true;
+ }
+ if (
+ block.originalContent?.includes(
+ '"disabledAttributesAction":"hide"'
+ )
+ ) {
+ settings.disabledAttributesAction = 'hide';
+ }
+ if (
+ block.originalContent?.includes( '"optionStyle":"dropdown"' )
+ ) {
+ settings.displayStyle = INNER_DROPDOWN;
+ return [ createBlock( INNER_DROPDOWN ) ];
+ }
+ settings.displayStyle = INNER_CHIPS;
+ return [ createBlock( INNER_CHIPS ) ];
+ }
+
+ if ( block.innerBlocks?.length ) {
+ return [
+ {
+ ...block,
+ innerBlocks: migrateInnerBlocks(
+ block.innerBlocks,
+ settings
+ ),
+ },
+ ];
+ }
+
+ return [ block ];
+ } );
+}
+
+const deprecated = [
+ {
+ save,
+ isEligible(
+ _attributes: Record< string, unknown >,
+ innerBlocks: BlockInstance[]
+ ) {
+ return containsLegacyAttributeOptionsBlock( innerBlocks );
+ },
+ migrate(
+ attributes: Record< string, unknown >,
+ innerBlocks: BlockInstance[]
+ ) {
+ const settings: MigratedAttributeSettings = {
+ displayStyle: metadata.attributes.displayStyle.default,
+ autoselect: metadata.attributes.autoselect.default,
+ disabledAttributesAction: metadata.attributes
+ .disabledAttributesAction.default as 'disable' | 'hide',
+ };
+
+ const migratedInnerBlocks = migrateInnerBlocks(
+ innerBlocks,
+ settings
+ );
+
+ return [
+ {
+ ...attributes,
+ displayStyle: settings.displayStyle,
+ autoselect: settings.autoselect,
+ disabledAttributesAction: settings.disabledAttributesAction,
+ },
+ migratedInnerBlocks,
+ ];
+ },
+ },
+];
+
+export default deprecated;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index 756cb7f0d88..17bed069750 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -1,8 +1,10 @@
/**
* External dependencies
*/
-import { useState } from '@wordpress/element';
+import { useMemo, useState } from '@wordpress/element';
import {
+ BlockContextProvider,
+ InspectorControls,
useBlockProps,
useInnerBlocksProps,
store as blockEditorStore,
@@ -12,17 +14,37 @@ import { BlockInstance, type BlockEditProps } from '@wordpress/blocks';
import { useSelect } from '@wordpress/data';
import {
CustomDataProvider,
+ useCustomDataContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { isProductResponseItem } from '@woocommerce/entities';
+import type { ProductResponseAttributeItem } from '@woocommerce/types';
+import { __ } from '@wordpress/i18n';
+import {
+ ToggleControl,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
/**
* Internal dependencies
*/
import { DEFAULT_ATTRIBUTES } from './constants';
+import {
+ DisplayStyleSwitcher,
+ resetDisplayStyleBlock,
+} from '../../../product-filters/components/display-style-switcher';
+import type { SelectableItemsContext } from '../../../../types/type-defs/selectable-items';
+
+const INNER_CHIPS = 'woocommerce/product-filter-chips';
interface Attributes {
className?: string;
+ displayStyle: string;
+ autoselect: boolean;
+ disabledAttributesAction: 'disable' | 'hide';
}
type AttributeItemProps = {
@@ -32,39 +54,76 @@ type AttributeItemProps = {
};
function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
+ const { data: attribute } =
+ useCustomDataContext< ProductResponseAttributeItem >( 'attribute' );
+
+ const selectableContext = useMemo( () => {
+ let items = [];
+ if (
+ attribute &&
+ Array.isArray( attribute?.terms ) &&
+ attribute.terms.length > 0
+ ) {
+ items = attribute.terms.map( ( term ) => ( {
+ id: `${ attribute.taxonomy }-${ term.slug }`,
+ label: term.name,
+ value: term.slug,
+ ariaLabel: term.name,
+ } ) );
+ }
+
+ return {
+ items,
+ selectionMode: 'single' as const,
+ storeNamespace: 'woocommerce/add-to-cart-with-options',
+ groupLabel: '',
+ } satisfies SelectableItemsContext< {
+ label: string;
+ ariaLabel: string;
+ } >;
+ }, [ attribute ] );
+
const blockPreviewProps = useBlockPreview( {
blocks,
} );
- const innerBlocksProps = useInnerBlocksProps(
- { role: 'listitem' },
- { templateLock: 'insert' }
- );
+ const innerBlocksProps = useInnerBlocksProps( { role: 'listitem' } );
- return (
- <>
- { isSelected ? <div { ...innerBlocksProps } /> : <></> }
+ if ( ! attribute ) {
+ return null;
+ }
- <div
- role="listitem"
- style={ { display: isSelected ? 'none' : undefined } }
- >
+ return (
+ <BlockContextProvider
+ value={ {
+ 'woocommerce/selectableItems': selectableContext,
+ } }
+ >
+ { isSelected ? (
+ <div { ...innerBlocksProps } />
+ ) : (
+ // We don't need these elements to be interactive with the
+ // keyboard because the first attribute blocks are always
+ // editable. We allow clicking on the blocks of other attributes
+ // but it's not critical, so we disable the keyboard events.
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
{ ...blockPreviewProps }
- role="button"
tabIndex={ 0 }
onClick={ onSelect }
- onKeyDown={ onSelect }
- />
- </div>
- </>
+ >
+ <div { ...innerBlocksProps } />
+ </div>
+ ) }
+ </BlockContextProvider>
);
}
export default function AttributeItemTemplateEdit(
props: BlockEditProps< Attributes >
) {
- const { clientId } = props;
- const { className } = props.attributes;
+ const { attributes, setAttributes, clientId } = props;
+ const { className, displayStyle, autoselect, disabledAttributesAction } =
+ attributes;
const blockProps = useBlockProps( {
className,
@@ -80,7 +139,9 @@ export default function AttributeItemTemplateEdit(
const { blocks } = useSelect(
( select ) => {
const { getBlocks } = select( blockEditorStore );
- return { blocks: getBlocks( clientId ) };
+ return {
+ blocks: getBlocks( clientId ),
+ };
},
[ clientId ]
);
@@ -89,25 +150,139 @@ export default function AttributeItemTemplateEdit(
useState< number >();
return (
- <div { ...blockProps } role="list">
- { productAttributes.map( ( attribute ) => (
- <CustomDataProvider
- key={ attribute.id }
- id="attribute"
- data={ attribute }
+ <>
+ <InspectorControls>
+ <ToolsPanel
+ label={ __( 'Style', 'woocommerce' ) }
+ resetAll={ () => {
+ setAttributes( { displayStyle: INNER_CHIPS } );
+ resetDisplayStyleBlock( clientId, INNER_CHIPS );
+ } }
+ >
+ <ToolsPanelItem
+ hasValue={ () => displayStyle !== INNER_CHIPS }
+ label={ __( 'Style', 'woocommerce' ) }
+ onDeselect={ () => {
+ setAttributes( { displayStyle: INNER_CHIPS } );
+ resetDisplayStyleBlock( clientId, INNER_CHIPS );
+ } }
+ isShownByDefault
+ >
+ <div>
+ <span className="screen-reader-text">
+ { __( 'Style', 'woocommerce' ) }
+ </span>
+ <DisplayStyleSwitcher
+ clientId={ clientId }
+ currentStyle={ displayStyle }
+ onChange={ ( value ) => {
+ setAttributes( {
+ displayStyle: value,
+ } );
+ } }
+ />
+ </div>
+ </ToolsPanelItem>
+ </ToolsPanel>
+ <ToolsPanel
+ label={ __( 'Auto-select', 'woocommerce' ) }
+ resetAll={ () =>
+ setAttributes( {
+ autoselect: false,
+ disabledAttributesAction: 'disable',
+ } )
+ }
>
- <AttributeItem
- blocks={ blocks }
- isSelected={
- ( selectedAttributeItem ||
- productAttributes[ 0 ]?.id ) === attribute.id
+ <ToolsPanelItem
+ label={ __(
+ 'Auto-select when only one option is available',
+ 'woocommerce'
+ ) }
+ hasValue={ () => autoselect }
+ onDeselect={ () =>
+ setAttributes( { autoselect: false } )
}
- onSelect={ () =>
- setSelectedAttributeItem( attribute.id )
+ isShownByDefault
+ >
+ <ToggleControl
+ label={ __(
+ 'Auto-select when only one option is available',
+ 'woocommerce'
+ ) }
+ help={ __(
+ 'Automatically select options on page load or after the shopper changes attributes, when only one valid choice is available.',
+ 'woocommerce'
+ ) }
+ checked={ autoselect }
+ onChange={ () =>
+ setAttributes( { autoselect: ! autoselect } )
+ }
+ __nextHasNoMarginBottom
+ />
+ </ToolsPanelItem>
+ <ToolsPanelItem
+ label={ __( 'Invalid options', 'woocommerce' ) }
+ hasValue={ () =>
+ disabledAttributesAction !== 'disable'
}
- />
- </CustomDataProvider>
- ) ) }
- </div>
+ onDeselect={ () =>
+ setAttributes( {
+ disabledAttributesAction: 'disable',
+ } )
+ }
+ isShownByDefault
+ >
+ <ToggleGroupControl
+ label={ __( 'Invalid options', 'woocommerce' ) }
+ help={ __(
+ 'Control the display of invalid options.',
+ 'woocommerce'
+ ) }
+ value={ disabledAttributesAction }
+ onChange={ ( value ) => {
+ if ( value === 'hide' || value === 'disable' ) {
+ setAttributes( {
+ disabledAttributesAction: value,
+ } );
+ }
+ } }
+ isBlock
+ size="__unstable-large"
+ >
+ <ToggleGroupControlOption
+ value="disable"
+ label={ __( 'Grayed-out', 'woocommerce' ) }
+ />
+ <ToggleGroupControlOption
+ value="hide"
+ label={ __( 'Hidden', 'woocommerce' ) }
+ />
+ </ToggleGroupControl>
+ </ToolsPanelItem>
+ </ToolsPanel>
+ </InspectorControls>
+
+ <div { ...blockProps } role="list">
+ { productAttributes.map( ( attribute ) => (
+ <CustomDataProvider
+ key={ attribute.id }
+ id="attribute"
+ data={ attribute }
+ >
+ <AttributeItem
+ blocks={ blocks }
+ isSelected={
+ ( selectedAttributeItem ||
+ productAttributes[ 0 ]?.id ) ===
+ attribute.id
+ }
+ onSelect={ () =>
+ setSelectedAttributeItem( attribute.id )
+ }
+ />
+ </CustomDataProvider>
+ ) ) }
+ </div>
+ </>
);
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/index.tsx
index 2c8fcad667a..0da5edf1b28 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/index.tsx
@@ -10,6 +10,8 @@ import { Icon, button } from '@wordpress/icons';
import metadata from './block.json';
import AttributeItemTemplateEdit from './edit';
import AttributeItemTemplateSave from './save';
+import deprecated from './deprecated';
+import './style.scss';
registerBlockType( metadata, {
edit: AttributeItemTemplateEdit,
@@ -22,4 +24,5 @@ registerBlockType( metadata, {
),
},
save: AttributeItemTemplateSave,
+ deprecated,
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/style.scss
new file mode 100644
index 00000000000..bc3b19492fd
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/style.scss
@@ -0,0 +1,4 @@
+:where(.wp-block-woocommerce-add-to-cart-with-options-variation-selector-attribute .wc-block-dropdown__select) {
+ height: 3em;
+ padding: 0.25em 1em;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/deprecated.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/deprecated.ts
new file mode 100644
index 00000000000..4143c273e34
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/test/deprecated.ts
@@ -0,0 +1,227 @@
+/**
+ * External dependencies
+ */
+import type { BlockInstance } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import metadata from '../block.json';
+import deprecated from '../deprecated';
+
+function createTestBlock( block: {
+ clientId: string;
+ name: string;
+ attributes?: Record< string, unknown >;
+ innerBlocks?: BlockInstance[];
+ originalContent?: string;
+} ): BlockInstance {
+ return {
+ isValid: true,
+ attributes: {},
+ innerBlocks: [],
+ ...block,
+ };
+}
+
+jest.mock( '@wordpress/blocks', () => {
+ const actual = jest.requireActual( '@wordpress/blocks' );
+
+ return {
+ ...actual,
+ createBlock: jest.fn(
+ (
+ name: string,
+ attributes = {},
+ innerBlocks: BlockInstance[] = []
+ ) => ( {
+ clientId: `mock-${ name }`,
+ name,
+ attributes,
+ innerBlocks,
+ isValid: true,
+ } )
+ ),
+ };
+} );
+
+const LEGACY_ATTRIBUTE_OPTIONS_BLOCK =
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options';
+const INNER_CHIPS = 'woocommerce/product-filter-chips';
+const INNER_DROPDOWN = 'woocommerce/dropdown';
+
+const { isEligible, migrate } = deprecated[ 0 ];
+
+type MigrateResult = [ Record< string, unknown >, BlockInstance[] ];
+
+function runMigrate(
+ attributes: Record< string, unknown >,
+ innerBlocks: BlockInstance[]
+): MigrateResult {
+ return migrate( attributes, innerBlocks ) as MigrateResult;
+}
+
+function createLegacyMissingBlock( originalContent = '' ): BlockInstance {
+ return createTestBlock( {
+ clientId: 'legacy-options',
+ name: 'core/missing',
+ attributes: {
+ originalName: LEGACY_ATTRIBUTE_OPTIONS_BLOCK,
+ },
+ originalContent,
+ } );
+}
+
+function createAttributeNameBlock(): BlockInstance {
+ return createTestBlock( {
+ clientId: 'attribute-name',
+ name: 'woocommerce/add-to-cart-with-options-variation-selector-attribute-name',
+ } );
+}
+
+function createGroupWithInnerBlocks(
+ innerBlocks: BlockInstance[]
+): BlockInstance {
+ return createTestBlock( {
+ clientId: 'group',
+ name: 'core/group',
+ innerBlocks,
+ } );
+}
+
+describe( 'Variation Selector Attribute block deprecation', () => {
+ describe( 'isEligible', () => {
+ it( 'returns false when there is no legacy attribute options block', () => {
+ const innerBlocks = [ createAttributeNameBlock() ];
+
+ expect( isEligible( {}, innerBlocks ) ).toBe( false );
+ } );
+
+ it( 'returns true when a legacy attribute options block is a direct inner block', () => {
+ const innerBlocks = [
+ createAttributeNameBlock(),
+ createLegacyMissingBlock(),
+ ];
+
+ expect( isEligible( {}, innerBlocks ) ).toBe( true );
+ } );
+
+ it( 'returns true when a legacy attribute options block is nested in inner blocks', () => {
+ const innerBlocks = [
+ createGroupWithInnerBlocks( [
+ createAttributeNameBlock(),
+ createLegacyMissingBlock(),
+ ] ),
+ ];
+
+ expect( isEligible( {}, innerBlocks ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'migrate', () => {
+ it( 'replaces a legacy dropdown options block and sets displayStyle to dropdown', () => {
+ const attributes = {
+ displayStyle: INNER_CHIPS,
+ autoselect: true,
+ disabledAttributesAction: 'hide' as const,
+ customAttribute: 'preserved',
+ };
+ const innerBlocks = [
+ createAttributeNameBlock(),
+ createLegacyMissingBlock(
+ '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options {"optionStyle":"dropdown"} /-->'
+ ),
+ ];
+
+ const [ migratedAttributes, migratedInnerBlocks ] = runMigrate(
+ attributes,
+ innerBlocks
+ );
+
+ expect( migratedAttributes ).toEqual( {
+ ...attributes,
+ displayStyle: INNER_DROPDOWN,
+ autoselect: metadata.attributes.autoselect.default,
+ disabledAttributesAction:
+ metadata.attributes.disabledAttributesAction.default,
+ } );
+ expect( migratedInnerBlocks ).toHaveLength( 2 );
+ expect( migratedInnerBlocks[ 0 ].name ).toBe(
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute-name'
+ );
+ expect( migratedInnerBlocks[ 1 ].name ).toBe( INNER_DROPDOWN );
+ } );
+
+ it( 'replaces a legacy chips options block and sets displayStyle to chips', () => {
+ const attributes = {
+ displayStyle: INNER_DROPDOWN,
+ };
+ const innerBlocks = [
+ createLegacyMissingBlock(
+ '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options {"optionStyle":"pills"} /-->'
+ ),
+ ];
+
+ const [ migratedAttributes, migratedInnerBlocks ] = runMigrate(
+ attributes,
+ innerBlocks
+ );
+
+ expect( migratedAttributes.displayStyle ).toBe( INNER_CHIPS );
+ expect( migratedInnerBlocks ).toHaveLength( 1 );
+ expect( migratedInnerBlocks[ 0 ].name ).toBe( INNER_CHIPS );
+ } );
+
+ it( 'defaults to chips when legacy block content does not indicate dropdown', () => {
+ const innerBlocks = [ createLegacyMissingBlock() ];
+
+ const [ migratedAttributes, migratedInnerBlocks ] = runMigrate(
+ {},
+ innerBlocks
+ );
+
+ expect( migratedAttributes.displayStyle ).toBe( INNER_CHIPS );
+ expect( migratedInnerBlocks[ 0 ].name ).toBe( INNER_CHIPS );
+ } );
+
+ it( 'uses block.json defaults for autoselect and disabledAttributesAction', () => {
+ const innerBlocks = [ createLegacyMissingBlock() ];
+
+ const [ migratedAttributes ] = runMigrate( {}, innerBlocks );
+
+ expect( migratedAttributes.autoselect ).toBe(
+ metadata.attributes.autoselect.default
+ );
+ expect( migratedAttributes.disabledAttributesAction ).toBe(
+ metadata.attributes.disabledAttributesAction.default
+ );
+ } );
+
+ it( 'replaces a nested legacy attribute options block', () => {
+ const innerBlocks = [
+ createGroupWithInnerBlocks( [
+ createAttributeNameBlock(),
+ createLegacyMissingBlock(
+ '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options {"optionStyle":"dropdown"} /-->'
+ ),
+ ] ),
+ ];
+
+ const [ migratedAttributes, migratedInnerBlocks ] = runMigrate(
+ {},
+ innerBlocks
+ );
+
+ expect( migratedAttributes.displayStyle ).toBe( INNER_DROPDOWN );
+ expect( migratedInnerBlocks ).toHaveLength( 1 );
+ expect( migratedInnerBlocks[ 0 ].name ).toBe( 'core/group' );
+ expect( migratedInnerBlocks[ 0 ].innerBlocks ).toHaveLength( 2 );
+ expect( migratedInnerBlocks[ 0 ].innerBlocks[ 0 ].name ).toBe(
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute-name'
+ );
+ expect( migratedInnerBlocks[ 0 ].innerBlocks[ 1 ].name ).toBe(
+ INNER_DROPDOWN
+ );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/edit.tsx
index ae64c819e48..57a662dece9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/edit.tsx
@@ -23,7 +23,6 @@ export default function AddToCartWithOptionsVariationSelectorEdit(
} );
const innerBlocksProps = useInnerBlocksProps( blockProps, {
template: ATTRIBUTE_ITEM_TEMPLATE,
- templateLock: 'all',
} );
return <div { ...innerBlocksProps } />;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index 049329d1a9c..2037075c6f7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -8,7 +8,6 @@ import {
getElement,
} from '@wordpress/interactivity';
import { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
-import type { ChangeEvent } from 'react';
import '@woocommerce/stores/woocommerce/products';
import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
import type { ProductResponseItem } from '@woocommerce/types';
@@ -16,35 +15,36 @@ import type { ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
-import type {
- AddToCartWithOptionsStore,
- Context as AddToCartWithOptionsStoreContext,
-} from '../frontend';
import {
normalizeAttributeName,
attributeNamesMatch,
getVariationAttributeValue,
} from '../../../base/utils/variations/attribute-matching';
-import setStyles from './set-styles';
+import type {
+ AddToCartWithOptionsStore,
+ Context as AddToCartWithOptionsStoreContext,
+} from '../frontend';
+import type { SelectableItem } from '../../../types/type-defs/selectable-items';
-type Option = {
- value: string;
+type VariationOptionItem = {
+ id: string;
label: string;
- isSelected: boolean;
+ value: string;
+ ariaLabel?: string;
};
type Context = AddToCartWithOptionsStoreContext & {
name: string;
selectedValue: string | null;
- option: Option;
- options: Option[];
+ variationAttributeOptions: VariationOptionItem[];
autoselect: boolean;
+ disabledAttributesAction?: 'disable' | 'hide';
};
-// Set selected pill styles for proper contrast.
-setStyles();
+type ToggleContext = Context & {
+ item?: SelectableItem;
+};
-// Stores are locked to prevent 3PD usage until the API is stable.
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -54,14 +54,6 @@ const { state: productsState } = store< ProductsStore >(
{ lock: universalLock }
);
-/**
- * Check if the attribute value is valid given the other selected attributes and
- * the available variations.
- *
- * To know if an attribute value is valid given the other selected attributes,
- * we make sure there is at least one available variation matching the current
- * selected attributes and the attribute value being checked.
- */
const isAttributeValueValid = ( {
attributeName,
attributeValue,
@@ -189,16 +181,12 @@ export type VariableProductAddToCartWithOptionsStore =
AddToCartWithOptionsStore & {
state: {
selectedAttributes: SelectedAttributes[];
- isOptionSelected: boolean;
- isOptionDisabled: boolean;
+ selectableItems: readonly SelectableItem[];
};
actions: {
setAttribute: ( attribute: string, value: string ) => void;
removeAttribute: ( attribute: string ) => void;
- handlePillClick: () => void;
- handleDropdownChange: (
- event: ChangeEvent< HTMLSelectElement >
- ) => void;
+ toggle: ( item?: SelectableItem ) => void;
autoselectAttributes: ( args: {
includedAttributes?: string[];
excludedAttributes?: string[];
@@ -221,31 +209,46 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
if ( ! context ) {
return [];
}
- return context.selectedAttributes;
- },
- get isOptionSelected() {
- const { selectedAttributes, option, name } =
- getContext< Context >();
-
- return selectedAttributes.some( ( attrObject ) => {
- return (
- attributeNamesMatch( attrObject.attribute, name ) &&
- attrObject.value === option.value
- );
- } );
+ return context.selectedAttributes || [];
},
- get isOptionDisabled() {
- const { name, option, selectedAttributes } =
- getContext< Context >();
-
- if ( option.value === '' ) {
- return false;
+ get selectableItems(): readonly SelectableItem[] {
+ const context = getContext< Context >();
+ if ( ! context ) {
+ return [];
+ }
+ const {
+ name,
+ disabledAttributesAction,
+ variationAttributeOptions,
+ } = context;
+ const { selectedAttributes } = state;
+ const hideInvalid = disabledAttributesAction === 'hide';
+
+ if ( ! Array.isArray( variationAttributeOptions ) ) {
+ return [];
}
- return ! isAttributeValueValid( {
- attributeName: name,
- attributeValue: option.value,
- selectedAttributes,
+ return variationAttributeOptions.map( ( row, index ) => {
+ const disabled = ! isAttributeValueValid( {
+ attributeName: name,
+ attributeValue: row.value,
+ selectedAttributes,
+ } );
+ const selected = selectedAttributes.some(
+ ( attrObject ) =>
+ attributeNamesMatch( attrObject.attribute, name ) &&
+ attrObject.value === row.value
+ );
+ return {
+ id: row.id,
+ label: row.label,
+ value: row.value,
+ ariaLabel: row.ariaLabel || row.label,
+ index,
+ selected,
+ disabled,
+ hidden: hideInvalid && disabled,
+ };
} );
},
},
@@ -292,28 +295,32 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
selectedAttributes.splice( index, 1 );
}
},
- handlePillClick() {
- const context = getContext< Context >();
+ toggle( itemArg?: SelectableItem | Event ) {
+ const context = getContext< ToggleContext >();
+ const item =
+ itemArg && ! ( itemArg instanceof Event )
+ ? itemArg
+ : context.item;
+ if ( ! item || item.hidden || item.disabled ) {
+ return;
+ }
+
+ const { name } = context;
+ const { selectedAttributes } = state;
+ const isCurrentlySelected = selectedAttributes.some(
+ ( attrObject ) =>
+ attributeNamesMatch( attrObject.attribute, name ) &&
+ attrObject.value === item.value
+ );
- if ( state.isOptionSelected ) {
+ if ( isCurrentlySelected ) {
context.selectedValue = '';
+ actions.setAttribute( name, '' );
} else {
- context.selectedValue = context.option.value;
- }
- actions.setAttribute( context.name, context.selectedValue );
- if ( context.selectedValue !== '' ) {
+ context.selectedValue = item.value;
+ actions.setAttribute( name, item.value );
actions.autoselectAttributes( {
- excludedAttributes: [ context.name ],
- } );
- }
- },
- handleDropdownChange( event: ChangeEvent< HTMLSelectElement > ) {
- const context = getContext< Context >();
- context.selectedValue = event.currentTarget.value;
- actions.setAttribute( context.name, context.selectedValue );
- if ( context.selectedValue !== '' ) {
- actions.autoselectAttributes( {
- excludedAttributes: [ context.name ],
+ excludedAttributes: [ name ],
} );
}
},
@@ -324,13 +331,13 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
includedAttributes?: Array< string >;
excludedAttributes?: Array< string >;
} = {} ) {
- const { autoselect, selectedAttributes } =
- getContext< Context >();
-
- if ( ! autoselect ) {
+ const context = getContext< Context >();
+ if ( ! context || ! context.autoselect ) {
return;
}
+ const { selectedAttributes } = state;
+
const { mainProductInContext: product } = productsState;
if ( ! product ) {
return;
@@ -389,10 +396,14 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
callbacks: {
setDefaultSelectedAttribute() {
const context = getContext< Context >();
+ if ( ! context.name ) {
+ return;
+ }
if ( context.selectedValue ) {
actions.setAttribute( context.name, context.selectedValue );
}
+
actions.autoselectAttributes( {
includedAttributes: [ context.name ],
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/set-styles.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/set-styles.ts
deleted file mode 100644
index 44685e0ce41..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/set-styles.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * Recursively searches up the DOM tree to find the first non-transparent color of the specified type.
- *
- * @param element - The DOM element to check for color.
- * @param colorType - Whether to check for color or background color.
- *
- * @return The computed RGB color string or null if not found.
- */
-function getClosestColor(
- element: Element | null,
- colorType: 'color' | 'backgroundColor'
-): string | null {
- if ( ! element ) {
- return null;
- }
- const computedColor = window.getComputedStyle( element )[ colorType ];
-
- // Skip transparent or default "empty" colors.
- if (
- computedColor !== 'rgba(0, 0, 0, 0)' &&
- computedColor !== 'transparent'
- ) {
- // Extract RGB values from the color string.
- const rgbValues = computedColor.match( /\d+/g );
-
- if ( ! rgbValues || rgbValues.length < 3 ) {
- return null;
- }
-
- const [ red, green, blue ] = rgbValues.slice( 0, 3 );
- return `rgb(${ red }, ${ green }, ${ blue })`;
- }
-
- // If current element has transparent color, check parent element.
- return getClosestColor( element.parentElement, colorType );
-}
-
-/**
- * Sets the appropriate styles for variation selector pills to ensure
- * visibility in both light and dark themes.
- *
- * This function swaps the text and background colors for selected pills
- * to create better contrast in all theme environments.
- */
-function setStyles(): void {
- /**
- * Get the background color of the body then set it as the background color
- * of the Variation Selector selected pills.
- *
- * We only set the background color, instead of the whole background. As
- * we only provide the option to customize the background color.
- */
-
- // For simplicity, we only consider the background color of the first Variation Selector pills.
- const pillsContainer = document.querySelector(
- '.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pills'
- );
-
- if ( ! pillsContainer ) {
- return;
- }
-
- const style = document.createElement( 'style' );
-
- const selectedPillColor =
- getClosestColor( pillsContainer, 'backgroundColor' ) || '#fff';
- const selectedPillBackgroundColor =
- getClosestColor( pillsContainer, 'color' ) || '#000';
-
- // We use :where here to reduce specificity so customized colors and theme CSS take priority.
- style.appendChild(
- document.createTextNode(
- `:where(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill:has(.wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input:checked)) {
- --pill-color: ${ selectedPillColor };
- --pill-background-color: ${ selectedPillBackgroundColor };
- }`
- )
- );
-
- document.head.appendChild( style );
-}
-
-export default setStyles;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
new file mode 100644
index 00000000000..37a2be40114
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/block.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "name": "woocommerce/dropdown",
+ "title": "Dropdown",
+ "description": "Display options in a dropdown.",
+ "category": "woocommerce",
+ "keywords": [ "WooCommerce" ],
+ "textdomain": "woocommerce",
+ "apiVersion": 3,
+ "ancestor": [
+ "woocommerce/add-to-cart-with-options-variation-selector-attribute"
+ ],
+ "supports": {
+ "interactivity": true
+ },
+ "usesContext": [ "woocommerce/selectableItems", "woocommerce/attributeId" ],
+ "attributes": {},
+ "viewScriptModule": "woocommerce/dropdown",
+ "style": "file:../woocommerce/dropdown-style.css",
+ "editorStyle": "file:../woocommerce/dropdown-editor.css"
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx
new file mode 100644
index 00000000000..ab202a0f39a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/edit.tsx
@@ -0,0 +1,99 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import clsx from 'clsx';
+import { decodeHtmlEntities } from '@woocommerce/utils';
+import { Disabled } from '@wordpress/components';
+import { useBlockProps } from '@wordpress/block-editor';
+import type { BlockEditProps } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import type { SelectableItemsBlockContext } from '../../types/type-defs/selectable-items';
+import './editor.scss';
+import './style.scss';
+
+export type BlockAttributes = Record< string, never >;
+
+export type EditProps = BlockEditProps< BlockAttributes > & {
+ context: SelectableItemsBlockContext< {
+ label?: string;
+ ariaLabel?: string;
+ } >;
+};
+
+function getOptionLabel( item: {
+ label: string | unknown;
+ ariaLabel?: string;
+} ): string {
+ if ( typeof item.label === 'string' && item.label.trim().length > 0 ) {
+ return decodeHtmlEntities( item.label.trim() );
+ }
+ if (
+ typeof item.ariaLabel === 'string' &&
+ item.ariaLabel.trim().length > 0
+ ) {
+ return item.ariaLabel.trim();
+ }
+ return '';
+}
+
+const Edit = ( props: EditProps ): JSX.Element => {
+ const { context } = props;
+ const selectableItems = context?.[ 'woocommerce/selectableItems' ] ?? {};
+ const isLoading = selectableItems.isLoading ?? false;
+ const items = Array.isArray( selectableItems.items )
+ ? selectableItems.items
+ : [];
+
+ const blockProps = useBlockProps( {
+ className: clsx( 'wc-block-dropdown', {
+ 'is-loading': isLoading,
+ } ),
+ } );
+
+ return (
+ <div { ...blockProps }>
+ <Disabled>
+ <fieldset className="wc-block-dropdown__fieldset">
+ <legend className="screen-reader-text">
+ { __( 'Choose an option', 'woocommerce' ) }
+ </legend>
+ { isLoading ? (
+ <div className="wc-block-dropdown__skeleton">
+ <div className="wc-block-dropdown__skeleton-option" />
+ </div>
+ ) : (
+ <select
+ className="wc-block-dropdown__select"
+ aria-label={ selectableItems.groupLabel }
+ >
+ <option value="">
+ { __( 'Choose an option', 'woocommerce' ) }
+ </option>
+ { items.map( ( item, index ) => {
+ const optionLabel = getOptionLabel( item );
+ if ( ! optionLabel ) {
+ return null;
+ }
+ return (
+ <option
+ key={ index }
+ value={ item.value }
+ disabled={ !! item.disabled }
+ >
+ { optionLabel }
+ </option>
+ );
+ } ) }
+ </select>
+ ) }
+ </fieldset>
+ </Disabled>
+ </div>
+ );
+};
+
+export default Edit;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/editor.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/editor.scss
new file mode 100644
index 00000000000..a98a362ce1e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/editor.scss
@@ -0,0 +1,16 @@
+.wc-block-dropdown__skeleton-option {
+ height: 1.75em;
+ border-radius: 2px;
+ background: color-mix(in srgb, currentColor 8%, transparent);
+ animation: wc-block-dropdown-pulse 1.2s ease-in-out infinite
+ alternate;
+}
+
+@keyframes wc-block-dropdown-pulse {
+ from {
+ opacity: 0.55;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts
new file mode 100644
index 00000000000..ead8dce0af0
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/frontend.ts
@@ -0,0 +1,95 @@
+/**
+ * External dependencies
+ */
+import { store, getContext } from '@wordpress/interactivity';
+
+/**
+ * Internal dependencies
+ */
+import type { SelectableItem } from '../../types/type-defs/selectable-items';
+
+type DropdownContext = {
+ storeNamespace: string;
+};
+
+type SelectableParent = {
+ state: {
+ selectableItems: readonly SelectableItem[];
+ };
+ actions: {
+ toggle: ( item?: SelectableItem ) => void;
+ navigate?: () => void;
+ };
+};
+
+function isToggleableItem(
+ item: SelectableItem | undefined
+): item is SelectableItem & { value: string } {
+ return (
+ !! item &&
+ typeof item.value === 'string' &&
+ item.value.length > 0 &&
+ ! item.disabled &&
+ ! item.hidden
+ );
+}
+
+store(
+ 'woocommerce/dropdown',
+ {
+ state: {
+ get selectValue(): string {
+ const { storeNamespace } = getContext< DropdownContext >();
+ const parent = store< SelectableParent >( storeNamespace );
+ const items = Array.isArray( parent.state.selectableItems )
+ ? parent.state.selectableItems
+ : [];
+ const selected = items.find( ( row ) => row.selected );
+ return selected?.value ?? '';
+ },
+ },
+ actions: {
+ onDropdownChange( event: Event ) {
+ const target = event.currentTarget;
+ if ( ! ( target instanceof HTMLSelectElement ) ) {
+ return;
+ }
+ const { storeNamespace } = getContext< DropdownContext >();
+ const parent = store< SelectableParent >( storeNamespace );
+ const value = target.value;
+
+ // Choosing the empty <option> unselects the selected option.
+ if ( value === '' ) {
+ const items = Array.isArray( parent.state.selectableItems )
+ ? parent.state.selectableItems
+ : [];
+ const selected = items.find( ( row ) => row.selected );
+ if (
+ selected &&
+ typeof parent.actions.toggle === 'function'
+ ) {
+ parent.actions.toggle( selected );
+ }
+ return;
+ }
+
+ const items = Array.isArray( parent.state.selectableItems )
+ ? parent.state.selectableItems
+ : [];
+ const row = items.find( ( item ) => item.value === value );
+
+ if ( ! isToggleableItem( row ) ) {
+ if ( typeof parent.actions.navigate === 'function' ) {
+ parent.actions.navigate();
+ }
+ return;
+ }
+
+ parent.actions.toggle( row );
+ },
+ },
+ },
+ { lock: true }
+);
+
+export type { DropdownContext };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx
new file mode 100644
index 00000000000..0987f575343
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/index.tsx
@@ -0,0 +1,18 @@
+/**
+ * External dependencies
+ */
+import { productFilterOptions } from '@woocommerce/icons';
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import Save from './save';
+
+registerBlockType( metadata, {
+ edit: Edit,
+ icon: productFilterOptions,
+ save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/save.tsx
new file mode 100644
index 00000000000..51dec4dfa0a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/save.tsx
@@ -0,0 +1,14 @@
+/**
+ * External dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+const Save = () => {
+ const blockProps = useBlockProps.save( {
+ className: 'wc-block-dropdown',
+ } );
+
+ return <div { ...blockProps } />;
+};
+
+export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss
new file mode 100644
index 00000000000..b959a272bed
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/dropdown/style.scss
@@ -0,0 +1,3 @@
+:where(.wc-block-dropdown__fieldset) {
+ display: contents;
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx
index c32522b9e95..9bbbaa61368 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/components/display-style-switcher/index.tsx
@@ -14,6 +14,27 @@ import {
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import { DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES } from '../../constants';
+
+function isDisplayStyleCandidate(
+ blockTypeName: string,
+ parentBlockName: string | undefined,
+ blockAncestor: readonly string[] | undefined
+): boolean {
+ if ( ! parentBlockName ) {
+ return false;
+ }
+ if (
+ DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES.includes( blockTypeName )
+ ) {
+ return false;
+ }
+ return blockAncestor?.includes( parentBlockName ) ?? false;
+}
+
export const DisplayStyleSwitcher = ( {
clientId,
currentStyle,
@@ -26,12 +47,13 @@ export const DisplayStyleSwitcher = ( {
const filterBlock = select( 'core/block-editor' ).getBlock( clientId );
const parentBlockName = filterBlock?.name;
- const displayStyleOptions = getBlockTypes().filter( ( blockType ) => {
- if ( parentBlockName ) {
- return blockType.ancestor?.includes( parentBlockName );
- }
- return [];
- } );
+ const displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
+ isDisplayStyleCandidate(
+ blockType.name,
+ parentBlockName,
+ blockType.ancestor
+ )
+ );
const { insertBlock, replaceBlock } = useDispatch( 'core/block-editor' );
@@ -100,7 +122,11 @@ export function resetDisplayStyleBlock(
const parentBlockName = filterBlock.name;
const displayStyleOptions = getBlockTypes().filter( ( blockType ) =>
- blockType.ancestor?.includes( parentBlockName )
+ isDisplayStyleCandidate(
+ blockType.name,
+ parentBlockName,
+ blockType.ancestor
+ )
);
const currentStyle = displayStyleOptions.find( ( blockType ) =>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
index cd4c5154d6e..d4a91c2ef7f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/constants.ts
@@ -5,5 +5,15 @@ export const EXCLUDED_BLOCKS = [
'woocommerce/product-filter-price',
'woocommerce/product-filter-status',
'woocommerce/product-collection',
+ 'woocommerce/add-to-cart-form',
+ 'woocommerce/add-to-cart-with-options',
'core/query',
];
+
+/**
+ * Block types that declare a product filter or variation-selector parent in `ancestor`
+ * but are not interchangeable display styles for the style toggle (chips vs dropdown).
+ */
+export const DISPLAY_STYLE_SWITCHER_EXCLUDED_BLOCK_NAMES: string[] = [
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute-name',
+];
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/active-filters/edit.tsx
index c643ffdb45e..ef58738d712 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
@@ -33,7 +33,7 @@ const Edit = () => {
<InitialDisabled>
<BlockContextProvider
value={ {
- woocommerceRemovableItems: {
+ 'woocommerce/removableItems': {
items: filtersPreview,
storeNamespace: 'woocommerce/product-filters',
} satisfies RemovableItemsContext,
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 a797ea532ba..8ffe4a6cf7d 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
@@ -211,7 +211,7 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- woocommerceSelectableItems: {
+ 'woocommerce/selectableItems': {
items:
attributeOptions.length === 0 && isPreview
? attributeOptionsPreview
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 e9010e3dc26..d674971bd2f 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": [ "woocommerceSelectableItems" ],
+ "usesContext": [ "woocommerce/selectableItems" ],
"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 9d62a022f74..5f6415965e6 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,7 +49,7 @@ const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
customOptionElement,
customLabelElement,
} = attributes;
- const selectableItems = context?.woocommerceSelectableItems ?? {};
+ const selectableItems = context?.[ 'woocommerce/selectableItems' ] ?? {};
const isLoading = selectableItems.isLoading ?? false;
const items = Array.isArray( selectableItems.items )
? selectableItems.items
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
index b031b620259..f5d1ae8117a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/__tests__/frontend.test.ts
@@ -4,7 +4,9 @@
import type { ChipsStore } from '../frontend';
const mockGetContext = jest.fn();
+const mockGetElement = jest.fn();
const mockParentToggle = jest.fn();
+const mockGetClosestColor = jest.fn();
let mockRegisteredStore: ChipsStore | null = null;
@@ -34,6 +36,7 @@ jest.mock(
'@wordpress/interactivity',
() => ( {
getContext: mockGetContext,
+ getElement: mockGetElement,
store: jest.fn( ( _name, definition ) => {
if ( definition ) {
mockRegisteredStore = definition;
@@ -45,11 +48,17 @@ jest.mock(
{ virtual: true }
);
+jest.mock( '../../../utils/get-closest-color', () => ( {
+ getClosestColor: ( ...args: unknown[] ) => mockGetClosestColor( ...args ),
+} ) );
+
describe( 'product filter chips interactivity store', () => {
beforeEach( () => {
jest.resetModules();
mockGetContext.mockReset();
+ mockGetElement.mockReset();
mockParentToggle.mockReset();
+ mockGetClosestColor.mockReset();
mockRegisteredStore = null;
jest.isolateModules( () => {
@@ -146,4 +155,99 @@ describe( 'product filter chips interactivity store', () => {
expect( mockParentToggle ).not.toHaveBeenCalled();
} );
+
+ it( 'sets chip CSS variables when not already defined', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ const element = document.createElement( 'div' );
+
+ mockGetElement.mockReturnValue( { ref: element } );
+ mockGetClosestColor.mockImplementation(
+ (
+ _el: Element,
+ colorType: 'color' | 'backgroundColor'
+ ): string | null => {
+ return colorType === 'backgroundColor'
+ ? 'rgb(255, 255, 255)'
+ : 'rgb(0, 0, 0)';
+ }
+ );
+
+ mockRegisteredStore.callbacks.initColors();
+
+ expect(
+ element.style.getPropertyValue(
+ '--wc-product-filter-chips-background'
+ )
+ ).toBe( 'rgb(255, 255, 255)' );
+ expect(
+ element.style.getPropertyValue( '--wc-product-filter-chips-text' )
+ ).toBe( 'rgb(0, 0, 0)' );
+ } );
+
+ it( 'does not calculate chip colors when already defined', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ const element = document.createElement( 'div' );
+ element.style.setProperty(
+ '--wc-product-filter-chips-text',
+ 'var(--wp--preset--color--contrast)'
+ );
+ element.style.setProperty(
+ '--wc-product-filter-chips-background',
+ 'var(--wp--preset--color--base)'
+ );
+
+ mockGetElement.mockReturnValue( { ref: element } );
+
+ mockRegisteredStore.callbacks.initColors();
+
+ expect( mockGetClosestColor ).not.toHaveBeenCalled();
+ expect(
+ element.style.getPropertyValue( '--wc-product-filter-chips-text' )
+ ).toBe( 'var(--wp--preset--color--contrast)' );
+ expect(
+ element.style.getPropertyValue(
+ '--wc-product-filter-chips-background'
+ )
+ ).toBe( 'var(--wp--preset--color--base)' );
+ } );
+
+ it( 'does not override theme contrast CSS variables from stylesheets', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Chips store was not registered.' );
+ }
+
+ const style = document.createElement( 'style' );
+ style.textContent = `.has-theme-vars {
+ --wc-product-filter-chips-background: rgb(1, 2, 3);
+ --wc-product-filter-chips-text: rgb(4, 5, 6);
+ }`;
+ document.head.appendChild( style );
+
+ const element = document.createElement( 'div' );
+ element.className = 'has-theme-vars';
+ document.body.appendChild( element );
+
+ mockGetElement.mockReturnValue( { ref: element } );
+
+ mockRegisteredStore.callbacks.initColors();
+
+ expect( mockGetClosestColor ).not.toHaveBeenCalled();
+ expect(
+ element.style.getPropertyValue(
+ '--wc-product-filter-chips-background'
+ )
+ ).toBe( '' );
+ expect(
+ element.style.getPropertyValue( '--wc-product-filter-chips-text' )
+ ).toBe( '' );
+
+ element.remove();
+ style.remove();
+ } );
} );
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 e1d50155a31..5f8d3aeda57 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
@@ -10,12 +10,13 @@
"ancestor": [
"woocommerce/product-filter-attribute",
"woocommerce/product-filter-taxonomy",
- "woocommerce/product-filter-status"
+ "woocommerce/product-filter-status",
+ "woocommerce/add-to-cart-with-options-variation-selector-attribute"
],
"supports": {
"interactivity": true
},
- "usesContext": [ "woocommerceSelectableItems" ],
+ "usesContext": [ "woocommerce/selectableItems", "woocommerce/attributeId" ],
"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 f71877743f3..b09cf5dce7b 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
@@ -53,7 +53,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
customSelectedChipBorder,
} = attributes;
const { isLoading = false, items = [] } =
- context?.woocommerceSelectableItems ?? {};
+ context?.[ 'woocommerce/selectableItems' ] ?? {};
const hasColorSwatches = items.some( ( item ) => 'color' in item );
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 924bb9f0967..f3522a7b051 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,7 +1,7 @@
/**
* External dependencies
*/
-import { store, getContext } from '@wordpress/interactivity';
+import { store, getContext, getElement } from '@wordpress/interactivity';
/**
* Internal dependencies
@@ -10,6 +10,7 @@ import type {
SelectableItem,
SelectableItemsParentStore,
} from '../../../../types/type-defs/selectable-items';
+import { getClosestColor } from '../../utils/get-closest-color';
type ChipsItem = SelectableItem< {
color?: string;
@@ -17,6 +18,8 @@ type ChipsItem = SelectableItem< {
} >;
const DEFAULT_DISPLAY_LIMIT = 15;
+const CHIP_BACKGROUND_VAR = '--wc-product-filter-chips-background';
+const CHIP_TEXT_VAR = '--wc-product-filter-chips-text';
type ChipsContext = {
storeNamespace: string;
@@ -34,6 +37,9 @@ type ChipsStore = {
toggle: () => void;
showAll: () => void;
};
+ callbacks: {
+ initColors: () => void;
+ };
};
function getParentStore( storeNamespace?: string ) {
@@ -56,6 +62,31 @@ function getCurrentItem(): ChipsItem | undefined {
return context.item;
}
+function getCssVariable( element: HTMLElement, property: string ): string {
+ return (
+ element.style.getPropertyValue( property ) ||
+ window.getComputedStyle( element ).getPropertyValue( property )
+ ).trim();
+}
+
+function initChipColors( element: HTMLElement ): void {
+ const style = element.style;
+
+ if ( ! getCssVariable( element, CHIP_BACKGROUND_VAR ) ) {
+ const backgroundColor = getClosestColor( element, 'backgroundColor' );
+ if ( backgroundColor ) {
+ style.setProperty( CHIP_BACKGROUND_VAR, backgroundColor );
+ }
+ }
+
+ if ( ! getCssVariable( element, CHIP_TEXT_VAR ) ) {
+ const textColor = getClosestColor( element, 'color' );
+ if ( textColor ) {
+ style.setProperty( CHIP_TEXT_VAR, textColor );
+ }
+ }
+}
+
const { state }: ChipsStore = store< ChipsStore >(
'woocommerce/product-filter-chips',
{
@@ -100,6 +131,16 @@ const { state }: ChipsStore = store< ChipsStore >(
context.isExpanded = true;
},
},
+ callbacks: {
+ initColors: () => {
+ const el = getElement();
+ if ( ! el.ref ) {
+ return;
+ }
+
+ initChipColors( el.ref );
+ },
+ },
},
{ lock: true }
);
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 e08e1623879..dec85799c21 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
@@ -1,4 +1,4 @@
-.wc-block-product-filter-chips fieldset {
+:where(.wc-block-product-filter-chips__fieldset) {
display: contents;
}
@@ -36,9 +36,18 @@
}
.wc-block-product-filter-chips__item[aria-checked="true"] {
- background: var(--wc-product-filters-text-color, CanvasText);
- color: var(--wc-product-filters-background-color, Canvas);
- border-color: var(--wc-product-filters-text-color, CanvasText);
+ background: var(
+ --wc-product-filter-chips-selected-background,
+ var(--wc-product-filter-chips-text, CanvasText)
+ );
+ color: var(
+ --wc-product-filter-chips-selected-text,
+ var(--wc-product-filter-chips-background, Canvas)
+ );
+ border-color: var(
+ --wc-product-filter-chips-selected-border,
+ var(--wc-product-filter-chips-text, CanvasText)
+ );
.has-selected-chip-text-color & {
color: var(--wc-product-filter-chips-selected-text);
@@ -128,7 +137,10 @@
}
&[aria-checked="true"] {
- border-color: var(--wc-product-filters-text-color, CanvasText);
+ border-color: var(
+ --wc-product-filter-chips-selected-border,
+ var(--wc-product-filter-chips-text, CanvasText)
+ );
}
}
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 ac8666d5a4a..73bbb50fd1d 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": [ "woocommerceRemovableItems" ],
+ "usesContext": [ "woocommerce/removableItems" ],
"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 517e6115238..2079d081bc1 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,7 +30,7 @@ const Edit = () => {
<InitialDisabled>
<BlockContextProvider
value={ {
- woocommerceRangeInput: {
+ 'woocommerce/rangeInput': {
...getPriceFilterData( data ),
isLoading,
},
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 05078a1f068..0a7fce0ef1b 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": [ "woocommerceRangeInput" ],
+ "usesContext": [ "woocommerce/rangeInput" ],
"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 aff4ae198b7..fd03932214d 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,7 @@ const PriceSliderEdit = ( {
customSlider,
} = attributes;
- const rangeInput = context.woocommerceRangeInput;
+ const rangeInput = context[ 'woocommerce/rangeInput' ];
const { isLoading } = rangeInput ?? {};
const blockProps = useBlockProps( {
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 a981f4cc72a..7c8988dc62e 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
@@ -187,7 +187,7 @@ const RatingFilterEdit = ( props: BlockEditProps< Attributes > ) => {
>
<BlockContextProvider
value={ {
- woocommerceSelectableItems: {
+ 'woocommerce/selectableItems': {
items: displayedOptions,
selectionMode: 'multiple' as const,
storeNamespace:
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 686ff058131..44f8b14b2b4 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", "woocommerceRemovableItems" ],
+ "usesContext": [ "queryId", "woocommerce/removableItems" ],
"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 15fcd60c1e2..060cbb1c9fe 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,7 +44,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
} = props;
const { customChipText, customChipBackground, customChipBorder, layout } =
attributes;
- const removableItemsContext = context.woocommerceRemovableItems;
+ const removableItemsContext = context[ 'woocommerce/removableItems' ];
const { items } = removableItemsContext ?? {};
// Extract attributes from block layout
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 115438a43b3..92c8d6ce2db 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
@@ -90,7 +90,7 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- woocommerceSelectableItems: {
+ 'woocommerce/selectableItems': {
items,
selectionMode: 'multiple' as const,
storeNamespace: 'woocommerce/product-filters',
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 8ffe6927d2c..8e65383d3c4 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
@@ -310,7 +310,7 @@ const Edit = ( props: EditProps ) => {
<InitialDisabled>
<BlockContextProvider
value={ {
- woocommerceSelectableItems: {
+ 'woocommerce/selectableItems': {
items:
termOptions.length === 0 && isPreview
? termOptionsPreview
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
index 6880691a1be..923f7e3b40c 100644
--- 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
@@ -14,7 +14,8 @@ export interface RangeInputContext {
}
export type RangeInputBlockContext = {
- woocommerceRangeInput: RangeInputContext;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'woocommerce/rangeInput': RangeInputContext;
};
/**
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
index 680b093c1d7..4779bb1259a 100644
--- 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
@@ -11,7 +11,8 @@ export interface RemovableItemsContext {
}
export type RemovableItemsBlockContext = {
- woocommerceRemovableItems: RemovableItemsContext;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'woocommerce/removableItems': RemovableItemsContext;
};
/**
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
index c98758a1612..c41c5bd6fcd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/selectable-items.ts
@@ -30,7 +30,8 @@ export interface SelectableItemsContext< T = unknown > {
}
export type SelectableItemsBlockContext< T = unknown > = {
- woocommerceSelectableItems: SelectableItemsContext< T >;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'woocommerce/selectableItems': SelectableItemsContext< T >;
};
/**
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-entries.js b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
index 23a0069f0a6..e9941ab10a4 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
@@ -29,10 +29,6 @@ const blocks = {
'add-to-cart-with-options-variation-selector-attribute-name': {
customDir: 'add-to-cart-with-options/variation-selector/attribute-name',
},
- 'add-to-cart-with-options-variation-selector-attribute-options': {
- customDir:
- 'add-to-cart-with-options/variation-selector/attribute-options',
- },
'add-to-cart-with-options-grouped-product-selector': {
customDir: 'add-to-cart-with-options/grouped-product-selector',
},
@@ -62,6 +58,7 @@ const blocks = {
'coming-soon': {},
'coupon-code': {},
'customer-account': {},
+ dropdown: {},
'email-content': {},
'featured-category': {
customDir: 'featured-items/featured-category',
diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
index 0c9deb05f35..1e4c99051dc 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
@@ -6,9 +6,9 @@ WooCommerce reusable inner blocks use small context protocols so they can render
| Context key | Purpose | Inner blocks |
| --- | --- | --- |
-| `woocommerceSelectableItems` | Select/deselect items | checkbox-list, chips |
-| `woocommerceRemovableItems` | Remove active items | removable-chips |
-| `woocommerceRangeInput` | Numeric range input | price-slider |
+| `woocommerce/selectableItems` | Select/deselect items (filters, variation attributes) | checkbox-list, chips, dropdown |
+| `woocommerce/removableItems` | Remove individual items (active filters) | removable-chips |
+| `woocommerce/rangeInput` | Numeric range input (price, slider) | price-slider |
## Shared pattern
@@ -30,7 +30,7 @@ Parents pass the context directly when rendering inner blocks because items are
```php
( new \WP_Block( $parsed_block, array(
- 'woocommerceSelectableItems' => $context,
+ 'woocommerce/selectableItems' => $context,
) ) )->render();
```
@@ -117,7 +117,7 @@ Checkbox-list and chips mirror parent items into child `state.items`, adding loc
## Removable Items
-Context key: `woocommerceRemovableItems`
+Context key: `woocommerce/removableItems`
Used by active-filter chips and similar removable item lists.
@@ -160,7 +160,7 @@ Reference implementations: `ProductFilterRemovableChips.php`, `ProductFilterClea
## Range Input
-Context key: `woocommerceRangeInput`
+Context key: `woocommerce/rangeInput`
Used by two-ended numeric controls such as price sliders.
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
index cad256f025c..57f2c992338 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.block_theme.spec.ts
@@ -238,12 +238,21 @@ test.describe( 'Add to Cart + Options Block', () => {
await expect( quantityInput ).toHaveValue( '1' );
} );
- // The radio input is visually hidden and, thus, not clickable. That's
- // why we need to select the <label> instead.
- const logoNoOption = page.locator( 'label:has-text("No")' );
- const colorBlueOption = page.locator( 'label:has-text("Blue")' );
- const colorGreenOption = page.locator( 'label:has-text("Green")' );
- const colorRedOption = page.locator( 'label:has-text("Red")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const logoNoOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'No', exact: true } );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const colorGreenOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Green', exact: true } );
+ const colorRedOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Red', exact: true } );
// We use the Add to Cart + Options class to make sure we don't select
// the Add to Cart button from the Related Products block.
const addToCartButton = page
@@ -372,11 +381,18 @@ test.describe( 'Add to Cart + Options Block', () => {
await page.goto( '/product/v-neck-t-shirt/' );
- // The radio input is visually hidden and, thus, not clickable. That's
- // why we need to select the <label> instead.
- const colorBlueOption = page.locator( 'label:has-text("Blue")' );
- const colorRedOption = page.locator( 'label:has-text("Red")' );
- const sizeLargeOption = page.locator( 'label:has-text("Large")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const colorRedOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Red', exact: true } );
+ const sizeLargeOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Size' } )
+ .getByRole( 'radio', { name: 'Large', exact: true } );
await colorBlueOption.click();
await sizeLargeOption.click();
@@ -453,12 +469,19 @@ test.describe( 'Add to Cart + Options Block', () => {
isOnlyCurrentEntityDirty: true,
} );
- await test.step( 'when in pills mode', async () => {
+ await test.step( 'when in chips mode', async () => {
await page.goto( '/product/custom-slug-variable/' );
- // Verify the pills show term names (not slugs).
- const petitOption = page.locator( 'label:has-text("Petit")' );
- const grandOption = page.locator( 'label:has-text("Grand")' );
+ // Verify the chips show term names (not slugs).
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const petitOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Taille' } )
+ .getByRole( 'radio', { name: 'Petit', exact: true } );
+ const grandOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Taille' } )
+ .getByRole( 'radio', { name: 'Grand', exact: true } );
const addToCartButton = page.getByRole( 'button', {
name: 'Add to cart',
exact: true,
@@ -790,7 +813,7 @@ test.describe( 'Add to Cart + Options Block', () => {
} );
} );
- test( "doesn't allow selecting invalid variations in pills mode", async ( {
+ test( "doesn't allow selecting invalid variations in chips mode", async ( {
page,
pageObject,
editor,
@@ -803,10 +826,15 @@ test.describe( 'Add to Cart + Options Block', () => {
await page.goto( '/product/hoodie/' );
- // The radio input is visually hidden and, thus, not clickable. That's
- // why we need to select the <label> instead.
- const logoYesOption = page.locator( 'label:has-text("Yes")' );
- const colorGreenOption = page.locator( 'label:has-text("Green")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const logoYesOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'Yes', exact: true } );
+ const colorGreenOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Green', exact: true } );
await expect( colorGreenOption ).toBeEnabled();
@@ -957,15 +985,24 @@ test.describe( 'Add to Cart + Options Block', () => {
await expect( quantityInput ).toHaveValue( '1' );
- const colorBlueOption = page.locator( 'label:has-text("Blue")' );
- const logoNoOption = page.locator( 'label:has-text("No")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const logoNoOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'No', exact: true } );
await colorBlueOption.click();
await logoNoOption.click();
await expect( quantityInput ).toHaveValue( '4' );
- const logoYesOption = page.locator( 'label:has-text("Yes")' );
+ const logoYesOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'Yes', exact: true } );
await logoYesOption.click();
await expect( quantityInput ).toHaveValue( '4' );
@@ -1025,9 +1062,9 @@ test.describe( 'Add to Cart + Options Block', () => {
await test.step( 'hides Product Quantity input when the product is sold individually', async () => {
await expect( quantityInput ).toBeVisible();
- const colorGreenOption = page.locator(
- 'label:has-text("Green")'
- );
+ const colorGreenOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Green', exact: true } );
await colorGreenOption.click();
await expect( quantityInput ).toBeHidden();
@@ -1191,8 +1228,15 @@ test.describe( 'Add to Cart + Options Block', () => {
await page.goto( '/product/hoodie' );
- const colorBlueOption = page.locator( 'label:has-text("Blue")' );
- const logoYesOption = page.locator( 'label:has-text("Yes")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const logoYesOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'Yes', exact: true } );
await colorBlueOption.click();
await logoYesOption.click();
@@ -1260,8 +1304,15 @@ test.describe( 'Add to Cart + Options Block', () => {
} ) => {
await pageObject.createPostWithProductBlock( 'hoodie' );
- const colorBlueOption = page.locator( 'label:has-text("Blue")' );
- const logoYesOption = page.locator( 'label:has-text("Yes")' );
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const logoYesOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Logo' } )
+ .getByRole( 'radio', { name: 'Yes', exact: true } );
await colorBlueOption.click();
await logoYesOption.click();
@@ -1479,8 +1530,8 @@ test.describe( 'Add to Cart + Options Block', () => {
}
} );
- for ( const optionStyle of [ 'pills', 'dropdown' ] as (
- | 'pills'
+ for ( const optionStyle of [ 'chips', 'dropdown' ] as (
+ | 'chips'
| 'dropdown'
)[] ) {
// eslint-disable-next-line playwright/expect-expect
@@ -1491,7 +1542,7 @@ test.describe( 'Add to Cart + Options Block', () => {
} ) => {
await pageObject.updateSingleProductTemplate();
- if ( optionStyle === 'pills' ) {
+ if ( optionStyle === 'chips' ) {
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
@@ -1566,6 +1617,7 @@ test.describe( 'Add to Cart + Options Block', () => {
);
} );
} );
+
test( `${ optionStyle }: Test the disabledAttributesAction block attribute`, async ( {
page,
pageObject,
@@ -1574,7 +1626,7 @@ test.describe( 'Add to Cart + Options Block', () => {
await test.step( `${ optionStyle }: Set the disabledAttributesAction block attribute to "disable"`, async () => {
await pageObject.updateSingleProductTemplate();
- if ( optionStyle === 'pills' ) {
+ if ( optionStyle === 'chips' ) {
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
@@ -1595,16 +1647,30 @@ test.describe( 'Add to Cart + Options Block', () => {
optionStyle
);
- await expect(
- page
- .getByLabel( 'Size' )
- .getByText( 'L', { exact: true } )
- ).toBeDisabled();
- await expect(
- page
- .getByLabel( 'Size' )
- .getByText( 'L', { exact: true } )
- ).not.toHaveAttribute( 'hidden' );
+ if ( optionStyle === 'chips' ) {
+ const sizeLChip = page
+ .getByRole( 'radiogroup', { name: 'Size' } )
+ .getByRole( 'radio', {
+ name: 'L',
+ exact: true,
+ } );
+ await expect( sizeLChip ).toBeDisabled();
+ await expect( sizeLChip ).not.toHaveAttribute(
+ 'hidden'
+ );
+ } else {
+ const sizeSelect = page
+ .locator( '.wp-block-add-to-cart-with-options' )
+ .getByLabel( 'Size', { exact: true } );
+ const sizeLOption = sizeSelect.getByRole( 'option', {
+ name: 'L',
+ exact: true,
+ } );
+ await expect( sizeLOption ).toBeDisabled();
+ await expect( sizeLOption ).not.toHaveAttribute(
+ 'hidden'
+ );
+ }
} );
await test.step( `${ optionStyle }: Set the disabledAttributesAction block attribute to "hide"`, async () => {
@@ -1625,18 +1691,27 @@ test.describe( 'Add to Cart + Options Block', () => {
optionStyle
);
- await expect(
- page
- .getByLabel( 'Size' )
- .getByText( 'L', { exact: true } )
- ).toBeDisabled();
- await expect(
- page
- .getByLabel( 'Size' )
- .getByText( 'L', { exact: true } )
- ).toBeHidden();
+ if ( optionStyle === 'chips' ) {
+ const sizeLChip = page
+ .getByRole( 'radiogroup', { name: 'Size' } )
+ .getByRole( 'radio', {
+ name: 'L',
+ exact: true,
+ } );
+ await expect( sizeLChip ).toBeHidden();
+ } else {
+ const sizeSelect = page
+ .locator( '.wp-block-add-to-cart-with-options' )
+ .getByLabel( 'Size', { exact: true } );
+ const sizeLOption = sizeSelect.getByRole( 'option', {
+ name: 'L',
+ exact: true,
+ } );
+ await expect( sizeLOption ).toBeHidden();
+ }
} );
} );
+
// eslint-disable-next-line playwright/expect-expect
test( `${ optionStyle }: Combining autoselect and disabledAttributesAction block attributes should work`, async ( {
page,
@@ -1698,14 +1773,14 @@ test.describe( 'Add to Cart + Options Block', () => {
} );
}
- test( `Pills: "X in cart" text displays correctly after auto-selection`, async ( {
+ test( `chips: "X in cart" text displays correctly after auto-selection`, async ( {
page,
pageObject,
editor,
} ) => {
await pageObject.updateSingleProductTemplate();
await pageObject.setVariationSelectorAttributes( {
- optionStyle: 'pills',
+ optionStyle: 'chips',
autoselect: true,
} );
await editor.saveSiteEditorEntities();
@@ -1717,14 +1792,14 @@ test.describe( 'Add to Cart + Options Block', () => {
await pageObject.selectVariationSelectorOptions(
'Color',
'Blue',
- 'pills'
+ 'chips'
);
// Type and Size should auto-select to T-shirt and XL
await pageObject.expectVariationSelectorOptions(
productAttributes,
{ Type: 'T-shirt', Color: 'Blue', Size: 'XL' },
- 'pills'
+ 'chips'
);
// Add to cart
@@ -1751,7 +1826,7 @@ test.describe( 'Add to Cart + Options Block', () => {
await pageObject.selectVariationSelectorOptions(
'Color',
'Blue',
- 'pills'
+ 'chips'
);
// After auto-selection completes, the button should show "1 in cart"
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.page.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.page.ts
index 175c43752ce..c20d80dc158 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.page.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/add-to-cart-with-options/add-to-cart-with-options.page.ts
@@ -114,7 +114,7 @@ class AddToCartWithOptionsPage {
autoselect,
disabledAttributesAction,
}: {
- optionStyle?: 'pills' | 'dropdown';
+ optionStyle?: 'chips' | 'dropdown';
autoselect?: boolean;
disabledAttributesAction?: 'disable' | 'hide';
} = {} ) {
@@ -126,21 +126,21 @@ class AddToCartWithOptionsPage {
// Verify inner blocks have loaded.
await expect(
this.editor.canvas
- .getByLabel(
- 'Block: Variation Selector: Attribute Options (Beta)'
- )
+ .getByLabel( 'Block: Variation Selector: Template (Beta)' )
.first()
).toBeVisible();
- const attributeOptionsBlock = await this.editor.getBlockByName(
- 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options'
+ const attributeTemplateBlock = await this.editor.getBlockByName(
+ 'woocommerce/add-to-cart-with-options-variation-selector-attribute'
);
- await this.editor.selectBlocks( attributeOptionsBlock.first() );
+ await this.editor.selectBlocks( attributeTemplateBlock.first() );
- // Option style attribute.
+ // Option style attribute (inner block titles: Chips, Dropdown).
if ( optionStyle ) {
+ const styleLabel =
+ optionStyle === 'dropdown' ? 'Dropdown' : 'Chips';
const optionStyleInput = page.getByRole( 'radio', {
- name: optionStyle,
+ name: styleLabel,
} );
await optionStyleInput.click();
}
@@ -195,22 +195,33 @@ class AddToCartWithOptionsPage {
async selectVariationSelectorOptions(
attributeName: string,
attributeValue: string,
- optionStyle: 'pills' | 'dropdown'
+ optionStyle: 'chips' | 'dropdown'
) {
if ( optionStyle === 'dropdown' ) {
- await this.page
- .getByLabel( attributeName )
- .selectOption( attributeValue );
- } else if ( attributeValue !== '' ) {
- await this.page
- .getByLabel( attributeName )
- .getByText( attributeValue )
+ const select = this.page.getByLabel( attributeName, {
+ exact: true,
+ } );
+ if ( attributeValue !== '' ) {
+ await expect(
+ select.getByRole( 'option', {
+ name: attributeValue,
+ exact: true,
+ } )
+ ).toBeAttached();
+ }
+ await select.selectOption( attributeValue );
+ return;
+ }
+ const group = this.page.getByRole( 'radiogroup', {
+ name: attributeName,
+ } );
+ if ( attributeValue !== '' ) {
+ await group
+ .getByRole( 'radio', { name: attributeValue, exact: true } )
.click();
} else {
- await this.page
- .getByLabel( attributeName )
- .locator( 'label:has(:checked)' )
- .click();
+ const selected = group.getByRole( 'radio', { checked: true } );
+ await selected.click();
}
}
@@ -221,18 +232,21 @@ class AddToCartWithOptionsPage {
variation: boolean;
visible: boolean;
}[],
- expectedValues: Record< string, string | RegExp > = {},
- optionStyle: 'pills' | 'dropdown'
+ expectedValues: Record< string, string > = {},
+ optionStyle: 'chips' | 'dropdown'
) {
for ( let {
name: attributeName,
options: attributeValues,
} of productAttributes ) {
- const attributeNameLocator = this.page.getByLabel( attributeName, {
- exact: true,
- } );
if ( optionStyle === 'dropdown' ) {
- let expectedValue: string | RegExp;
+ const attributeNameLocator = this.page.getByLabel(
+ attributeName,
+ {
+ exact: true,
+ }
+ );
+ let expectedValue: string;
if (
attributeName in expectedValues &&
expectedValues[ attributeName ] !== ''
@@ -246,27 +260,32 @@ class AddToCartWithOptionsPage {
);
continue;
}
+ const group = this.page.getByRole( 'radiogroup', {
+ name: attributeName,
+ } );
if (
attributeName in expectedValues &&
expectedValues[ attributeName ] !== ''
) {
attributeValues = attributeValues.filter(
( item ) => item !== expectedValues[ attributeName ]
- ); // Omit attributeName
+ );
await expect(
- attributeNameLocator.getByLabel(
- expectedValues[ attributeName ],
- { exact: true }
- )
+ group.getByRole( 'radio', {
+ name: String( expectedValues[ attributeName ] ),
+ exact: true,
+ } )
).toBeChecked();
}
if ( attributeValues.length ) {
for ( const attributeValue of attributeValues ) {
- await expect(
- attributeNameLocator.getByLabel( attributeValue, {
- exact: true,
- } )
- ).not.toBeChecked();
+ const radio = group.getByRole( 'radio', {
+ name: attributeValue,
+ exact: true,
+ } );
+ if ( ( await radio.count() ) > 0 ) {
+ await expect( radio ).not.toBeChecked();
+ }
}
}
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0daca2f8e17..b005bd27fe0 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -53529,12 +53529,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductFilterCheckboxList.php
- -
- message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 2
- path: src/Blocks/BlockTypes/ProductFilterChips.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilterChips\:\:render\(\) should return string but returns string\|false\.$#'
identifier: return.type
@@ -53553,12 +53547,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/ProductFilterChips.php
- -
- message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilterChips\:\:render\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
- identifier: class.notFound
- count: 1
- path: src/Blocks/BlockTypes/ProductFilterChips.php
-
-
message: '#^Access to property \$context on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
index 1709a50aac8..6b743797752 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationDescription.php
@@ -19,7 +19,6 @@ class VariationDescription extends AbstractBlock {
*/
protected $block_name = 'add-to-cart-with-options-variation-description';
-
/**
* Render the block.
*
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
index c4af4f47c7d..819dce6a4a9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
@@ -5,12 +5,10 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;
use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
use Automattic\WooCommerce\Blocks\BlockTypes\EnableBlockJsonAssetsTrait;
-use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils as AddToCartWithOptionsUtils;
use WP_Block;
/**
- * Block type for variation selector item in add to cart with options.
- * It's responsible to render each child attribute in a form of a list item.
+ * Block type for Variation Selector in the Add to Cart + Options block.
*/
class VariationSelectorAttribute extends AbstractBlock {
@@ -31,7 +29,7 @@ class VariationSelectorAttribute extends AbstractBlock {
* @param WP_Block $block Block instance.
* @return string Rendered block output.
*/
- protected function render( $attributes, $content, $block ): string {
+ protected function render( $attributes, $content, $block ) {
global $product;
if ( ! $product instanceof \WC_Product_Variable ) {
@@ -40,138 +38,284 @@ class VariationSelectorAttribute extends AbstractBlock {
$content = '';
- $product_attributes = $product->get_variation_attributes();
+ $product_attributes = $product->get_variation_attributes();
+ $available_values_by_attribute = $this->get_available_variation_values_by_attribute_slug();
foreach ( $product_attributes as $product_attribute_name => $product_attribute_terms ) {
- $content .= $this->get_product_row( $product_attribute_name, $product_attribute_terms, $block );
+ $content .= $this->render_attribute_row( $product_attribute_name, $product_attribute_terms, $block, $attributes, $available_values_by_attribute );
}
return $content;
}
/**
- * Get product row HTML.
+ * Get attribute row HTML.
*
* @param string $attribute_name Product Attribute Name.
* @param array $product_attribute_terms Product Attribute Terms.
* @param WP_Block $block The Block.
+ * @param array $attributes Template block attributes (displayStyle, autoselect, etc.).
+ * @param array $available_values_by_attribute Variation values keyed by attribute slug.
* @return string Row HTML
*/
- private function get_product_row( $attribute_name, $product_attribute_terms, $block ): string {
- global $product;
+ private function render_attribute_row( string $attribute_name, array $product_attribute_terms, WP_Block $block, array $attributes, array $available_values_by_attribute ): string {
+ $inner_blocks = $block->parsed_block['innerBlocks'] ?? array();
- $attribute_terms = $this->get_terms( $attribute_name, $product_attribute_terms );
- $product_variations = $product->get_available_variations( 'objects' );
+ if ( empty( $inner_blocks ) ) {
+ return '';
+ }
- // Filter out terms which are not available in any product variation.
- $attribute_terms = array_filter(
- $attribute_terms,
- function ( $term ) use ( $product_variations, $attribute_name ) {
- foreach ( $product_variations as $variation ) {
- $attributes = $variation->get_variation_attributes();
- if (
- $term['value'] === $attributes[ wc_variation_attribute_name( $attribute_name ) ] ||
- '' === $attributes[ wc_variation_attribute_name( $attribute_name ) ]
- ) {
- return true;
- }
- }
- }
+ $attribute_slug = wc_variation_attribute_name( $attribute_name );
+ $attribute_terms = $this->get_filtered_attribute_terms(
+ $attribute_name,
+ $product_attribute_terms,
+ $available_values_by_attribute[ $attribute_slug ] ?? array()
);
if ( empty( $attribute_terms ) ) {
return '';
}
- $block_content = AddToCartWithOptionsUtils::render_block_with_context(
- $block,
- array(
- 'woocommerce/attributeId' => 'wc_product_attribute_' . uniqid(),
- 'woocommerce/attributeName' => $attribute_name,
- 'woocommerce/attributeTerms' => $attribute_terms,
+ $default_selected = $this->get_default_selected_attribute( $attribute_slug, $attribute_terms );
+ $variation_items = $this->build_variation_selectable_items( $attribute_slug, $attribute_terms, $default_selected );
+ $attribute_label = wc_attribute_label( $attribute_name );
+ $attribute_id = 'wc_product_attribute_' . uniqid();
+ $context = array(
+ 'woocommerce/attributeId' => $attribute_id,
+ 'woocommerce/attributeName' => $attribute_name,
+ 'woocommerce/attributeTerms' => $attribute_terms,
+ 'woocommerce/selectableItems' => array(
+ 'items' => $variation_items,
+ 'selectionMode' => 'single',
+ 'storeNamespace' => 'woocommerce/add-to-cart-with-options',
+ 'groupLabel' => $attribute_label,
),
);
- // Render the inner blocks of the Variation Selector Item Template block with `dynamic` set to `false`
- // to prevent calling `render_callback` and ensure that no wrapper markup is included.
- return $block_content;
+ $inner_html = '';
+
+ foreach ( $inner_blocks as $inner_block ) {
+ $inner_block = $this->replace_legacy_attribute_options_block( $inner_block, $attributes );
+ $inner_html .= ( new WP_Block( $inner_block, $context ) )->render();
+ }
+
+ $interactive_context = array(
+ 'name' => $attribute_label,
+ 'variationAttributeOptions' => $variation_items,
+ 'selectedValue' => $default_selected,
+ 'autoselect' => $attributes['autoselect'] ?? false,
+ 'disabledAttributesAction' => $attributes['disabledAttributesAction'] ?? 'disable',
+ );
+
+ $interactive_attributes = array(
+ 'data-wp-interactive' => 'woocommerce/add-to-cart-with-options',
+ 'data-wp-init' => 'callbacks.setDefaultSelectedAttribute',
+ );
+
+ return sprintf(
+ '<div %s %s>%s</div>',
+ get_block_wrapper_attributes( $interactive_attributes ),
+ wp_interactivity_data_wp_context( $interactive_context ),
+ $inner_html
+ );
}
/**
- * Get product attributes terms.
+ * Replace legacy Attribute Options block and apply its settings to the parent attributes.
*
- * @param string $attribute_name Product Attribute Name.
- * @param array $attribute_terms Product Attribute Terms.
- * @return array[] Array of term data with structure:
- * [
- * 'label' => (string) Display label for the term.
- * 'value' => (string) Internal value/slug for the term.
- * 'isSelected' => (bool) Whether this term is the default selection.
- * ]
+ * @param array $inner_block The inner block to replace.
+ * @param array $attributes Parent block attributes, updated when attributes in the legacy Attribute Options block are found.
+ * @return array The replaced inner block.
*/
- protected function get_terms( $attribute_name, $attribute_terms ) {
- global $product;
+ private function replace_legacy_attribute_options_block( array $inner_block, array &$attributes ): array {
+ if ( 'woocommerce/add-to-cart-with-options-variation-selector-attribute-options' === $inner_block['blockName'] ) {
+ $legacy_attrs = $inner_block['attrs'] ?? array();
- $is_taxonomy = taxonomy_exists( $attribute_name );
+ if ( array_key_exists( 'autoselect', $legacy_attrs ) && true === $legacy_attrs['autoselect'] ) {
+ $attributes['autoselect'] = true;
+ }
- $selected_attribute = $product->get_variation_default_attribute( $attribute_name );
+ if ( array_key_exists( 'disabledAttributesAction', $legacy_attrs ) && 'hide' === $legacy_attrs['disabledAttributesAction'] ) {
+ $attributes['disabledAttributesAction'] = 'hide';
+ }
+
+ if ( array_key_exists( 'optionStyle', $legacy_attrs ) && 'dropdown' === $legacy_attrs['optionStyle'] ) {
+ $attributes['displayStyle'] = 'woocommerce/dropdown';
+ }
- if ( $is_taxonomy ) {
- $items = array_map(
- function ( $term ) use ( $attribute_name, $product, $selected_attribute ) {
- return array(
- 'value' => $term->slug,
- /**
- * Filter the variation option name.
- *
- * @since 9.7.0
- *
- * @param string $option_label The option label.
- * @param WP_Term|string|null $item Term object for taxonomies, option string for custom attributes.
- * @param string $attribute_name Name of the attribute.
- * @param WC_Product $product Product object.
- */
- 'label' => apply_filters(
- 'woocommerce_variation_option_name',
- $term->name,
- $term,
- $attribute_name,
- $product
- ),
- 'isSelected' => $selected_attribute === $term->slug,
- );
- },
- wc_get_product_terms( $product->get_id(), $attribute_name, array( 'fields' => 'all' ) ),
+ return array(
+ 'blockName' => 'woocommerce/dropdown' === $attributes['displayStyle'] ? 'woocommerce/dropdown' : 'woocommerce/product-filter-chips',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '',
+ 'innerContent' => array(),
);
+ } elseif ( isset( $inner_block['innerBlocks'] ) && is_array( $inner_block['innerBlocks'] ) && ! empty( $inner_block['innerBlocks'] ) ) {
+ foreach ( $inner_block['innerBlocks'] as $key => $child_inner_block ) {
+ $inner_block['innerBlocks'][ $key ] = $this->replace_legacy_attribute_options_block( $child_inner_block, $attributes );
+ }
+ }
+
+ return $inner_block;
+ }
+
+ /**
+ * Build filtered attribute term options for the variation selector.
+ *
+ * @param string $attribute_name Product attribute name.
+ * @param array $product_attribute_terms Custom attribute terms when not a taxonomy.
+ * @param array $available_values Available variation values for this attribute (value => true).
+ * @return array Attribute terms, or empty string when none match.
+ */
+ private function get_filtered_attribute_terms( string $attribute_name, array $product_attribute_terms, array $available_values ) {
+ global $product;
+
+ $selected_attribute = $product->get_variation_default_attribute( $attribute_name );
+ $terms = taxonomy_exists( $attribute_name )
+ ? wc_get_product_terms( $product->get_id(), $attribute_name, array( 'fields' => 'all' ) )
+ : $product_attribute_terms;
+
+ $attribute_terms = array();
+ if ( ! empty( $available_values ) ) {
+ $allows_any_value = isset( $available_values[''] );
+
+ foreach ( $terms as $term ) {
+ $option = $this->map_term_to_option( $term, $attribute_name, $product, $selected_attribute );
+
+ if ( ! isset( $option['value'] ) ) {
+ continue;
+ }
+
+ if ( $allows_any_value || isset( $available_values[ $option['value'] ] ) ) {
+ $attribute_terms[] = $option;
+ }
+ }
+ }
+
+ return $attribute_terms;
+ }
+
+ /**
+ * Build a lookup of attribute values used by available variations.
+ *
+ * @return array Map of attribute slug to set of values (keys are values).
+ */
+ private function get_available_variation_values_by_attribute_slug(): array {
+ global $product;
+
+ $product_variations = $product->get_available_variations( 'objects' );
+ $available_by_slug = array();
+
+ foreach ( $product_variations as $variation ) {
+ foreach ( $variation->get_variation_attributes() as $attribute_slug => $value ) {
+ $available_by_slug[ $attribute_slug ][ $value ] = true;
+ }
+ }
+
+ return $available_by_slug;
+ }
+
+ /**
+ * Get the default selected attribute.
+ *
+ * @param string $attribute_slug The attribute's slug.
+ * @param array $attribute_terms The attribute's terms.
+ * @return string|null The default selected attribute.
+ */
+ private function get_default_selected_attribute( string $attribute_slug, array $attribute_terms ): ?string {
+ if ( isset( $_GET[ $attribute_slug ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $raw = wp_unslash( $_GET[ $attribute_slug ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ if ( is_string( $raw ) ) {
+ $attribute_slug_from_request = sanitize_title( $raw );
+ foreach ( $attribute_terms as $attribute_term ) {
+ if ( sanitize_title( $attribute_term['value'] ) === $attribute_slug_from_request ) {
+ return $attribute_term['value'];
+ }
+ }
+ }
} else {
- $items = array_map(
- function ( $term ) use ( $attribute_name, $product, $selected_attribute ) {
- return array(
- 'value' => $term,
- /**
- * Filter the variation option name.
- *
- * @since 9.7.0
- *
- * @param string $option_label The option label.
- * @param WP_Term|string|null $item Term object for taxonomies, option string for custom attributes.
- * @param string $attribute_name Name of the attribute.
- * @param WC_Product $product Product object.
- */
- 'label' => apply_filters(
- 'woocommerce_variation_option_name',
- $term,
- null,
- $attribute_name,
- $product
- ),
- 'isSelected' => $selected_attribute === $term,
- );
- },
- $attribute_terms,
+ foreach ( $attribute_terms as $attribute_term ) {
+ if ( $attribute_term['isSelected'] ) {
+ return $attribute_term['value'];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Build selectable items for the inner block protocol and client context.
+ *
+ * @param string $attribute_slug Attribute slug.
+ * @param array $attribute_terms Terms from context.
+ * @param string|null $default_selected Default selected attribute value.
+ * @return array
+ */
+ private function build_variation_selectable_items( string $attribute_slug, array $attribute_terms, ?string $default_selected ): array {
+ $id_prefix = sanitize_title( $attribute_slug );
+ $items = array();
+
+ foreach ( $attribute_terms as $attribute_term ) {
+ if ( ! is_array( $attribute_term ) || ! isset( $attribute_term['value'], $attribute_term['label'] ) ) {
+ continue;
+ }
+ $value = (string) $attribute_term['value'];
+ $slug = sanitize_title( $value );
+ $items[] = array(
+ 'id' => $id_prefix . '-' . $slug,
+ 'label' => (string) $attribute_term['label'],
+ 'value' => $value,
+ 'ariaLabel' => (string) $attribute_term['label'],
+ 'selected' => $default_selected === $value,
);
}
return $items;
}
+
+ /**
+ * Map a taxonomy term or custom attribute option to the variation row option shape.
+ *
+ * @param \WP_Term|string $term Term object for taxonomies, option string for custom attributes.
+ * @param string $attribute_name Name of the attribute.
+ * @param \WC_Product $product Product object.
+ * @param string $selected_attribute Default selected attribute value.
+ * @return array
+ */
+ private function map_term_to_option( $term, string $attribute_name, \WC_Product $product, string $selected_attribute ): array {
+ if ( $term instanceof \WP_Term ) {
+ $value = $term->slug;
+ $label = $term->name;
+ $filter_item = $term;
+ } elseif ( is_string( $term ) ) {
+ $value = $term;
+ $label = $term;
+ $filter_item = null;
+ } else {
+ return array();
+ }
+
+ return array(
+ 'value' => $value,
+ /**
+ * Filter the variation option name.
+ *
+ * @since 9.7.0
+ *
+ * @param string $option_label The option label.
+ * @param \WP_Term|string|null $item Term object for taxonomies, option string for custom attributes.
+ * @param string $attribute_name Name of the attribute.
+ * @param \WC_Product $product Product object.
+ */
+ 'label' => apply_filters(
+ 'woocommerce_variation_option_name',
+ $label,
+ $filter_item,
+ $attribute_name,
+ $product
+ ),
+ 'isSelected' => $selected_attribute === $value,
+ );
+ }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeName.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeName.php
index 2f482d36b88..27056a9e420 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeName.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeName.php
@@ -48,7 +48,6 @@ class VariationSelectorAttributeName extends AbstractBlock {
$wrapper_attributes = get_block_wrapper_attributes(
array(
'class' => esc_attr( $classes_and_styles['classes'] ),
- 'for' => esc_attr( $attribute_id ),
'id' => esc_attr( $attribute_id . '_label' ),
'style' => esc_attr( $classes_and_styles['styles'] ),
)
@@ -57,7 +56,7 @@ class VariationSelectorAttributeName extends AbstractBlock {
$label_text = esc_html( wc_attribute_label( $attribute_name ) );
return sprintf(
- '<label %s>%s</label>',
+ '<span %s>%s</span>',
$wrapper_attributes,
$label_text
);
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php
deleted file mode 100644
index ba1122f3976..00000000000
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttributeOptions.php
+++ /dev/null
@@ -1,278 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;
-
-use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
-use Automattic\WooCommerce\Blocks\BlockTypes\EnableBlockJsonAssetsTrait;
-use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
-
-/**
- * Block type for variation selector attribute options in add to cart with options.
- * It's responsible to render the attribute options.
- */
-class VariationSelectorAttributeOptions extends AbstractBlock {
-
- use EnableBlockJsonAssetsTrait;
-
- /**
- * Block name.
- *
- * @var string
- */
- protected $block_name = 'add-to-cart-with-options-variation-selector-attribute-options';
-
- /**
- * Render the block.
- *
- * @param array $attributes Block attributes.
- * @param string $content Block content.
- * @param \WP_Block $block Block instance.
- * @return string Rendered block output.
- */
- protected function render( $attributes, $content, $block ): string {
- if (
- ! isset(
- $block->context['woocommerce/attributeName'],
- $block->context['woocommerce/attributeId'],
- $block->context['woocommerce/attributeTerms']
- )
- ) {
- return '';
- }
-
- $attribute_slug = wc_variation_attribute_name( $block->context['woocommerce/attributeName'] );
-
- $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes, array(), array( 'extra_classes' ) );
-
- $option_style = array_key_exists( 'optionStyle', $attributes ) ? $attributes['optionStyle'] : null;
-
- // During the beta period, `optionStyle` was called `style`, so we check
- // `style` for backwards compatibility.
- if ( ! $option_style && array_key_exists( 'style', $attributes ) && 'dropdown' === $attributes['style'] ) {
- $option_style = 'dropdown';
- }
-
- $wrapper_attributes = get_block_wrapper_attributes(
- array(
- 'class' => $classes_and_styles['classes'],
- 'style' => $classes_and_styles['styles'],
- )
- );
-
- if ( 'dropdown' === $option_style ) {
- $content = $this->render_dropdown( $attributes, $content, $block );
- } else {
- $content = $this->render_pills( $attributes, $content, $block );
- }
-
- return sprintf(
- '<div %s>%s</div>',
- $wrapper_attributes,
- $content
- );
- }
-
- /**
- * Get the normalized version of the attributes.
- *
- * @param array $attributes The element's attributes.
- * @param array $default_attributes The element's default attributes.
- * @return string The HTML element's attributes.
- */
- public static function get_normalized_attributes( $attributes, $default_attributes = array() ) {
- $normalized_attributes = array();
-
- $merged_attributes = array_merge( $default_attributes, $attributes );
-
- foreach ( $merged_attributes as $key => $value ) {
- if ( is_null( $value ) ) {
- continue;
- }
- if ( is_array( $value ) || is_object( $value ) ) {
- $value = wp_json_encode(
- $value,
- JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
- );
- }
- $normalized_attributes[] = sprintf( '%s="%s"', esc_attr( $key ), esc_attr( $value ) );
- }
-
- return implode( ' ', $normalized_attributes );
- }
-
- /**
- * Get the default selected attribute.
- *
- * @param string $attribute_slug The attribute's slug.
- * @param array $attribute_terms The attribute's terms.
- * @return string|null The default selected attribute.
- */
- protected function get_default_selected_attribute( $attribute_slug, $attribute_terms ) {
- if ( isset( $_GET[ $attribute_slug ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $raw = wp_unslash( $_GET[ $attribute_slug ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( is_string( $raw ) ) {
- $attribute_slug_from_request = sanitize_title( $raw );
- foreach ( $attribute_terms as $attribute_term ) {
- if ( sanitize_title( $attribute_term['value'] ) === $attribute_slug_from_request ) {
- return $attribute_term['value'];
- }
- }
- }
- } else {
- foreach ( $attribute_terms as $attribute_term ) {
- if ( $attribute_term['isSelected'] ) {
- return $attribute_term['value'];
- }
- }
- }
-
- return null;
- }
-
- /**
- * Render the attribute options as pills.
- *
- * @param array $attributes Block attributes.
- * @param string $content Block content.
- * @param \WP_Block $block Block instance.
- * @return string The pills.
- */
- protected function render_pills( $attributes, $content, $block ) {
- $attribute_id = $block->context['woocommerce/attributeId'];
- $attribute_slug = wc_variation_attribute_name( $block->context['woocommerce/attributeName'] );
- $attribute_terms = $block->context['woocommerce/attributeTerms'];
- $autoselect = $attributes['autoselect'] ?? false;
- $disabled_attributes_action = $attributes['disabledAttributesAction'] ?? 'disable';
-
- wp_interactivity_state(
- 'woocommerce/add-to-cart-with-options',
- array(
- 'isOptionSelected' =>
- function () {
- $context = wp_interactivity_get_context();
-
- return $context['option']['value'] === $context['selectedValue'];
- },
- )
- );
-
- $pills = '';
- foreach ( $attribute_terms as $attribute_term ) {
- $input = sprintf(
- '<input type="radio" %s/>',
- $this->get_normalized_attributes(
- array(
- 'class' => 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill-input',
- 'name' => $attribute_slug,
- 'value' => $attribute_term['value'],
- 'data-wp-bind--checked' => 'state.isOptionSelected',
- 'data-wp-bind--disabled' => 'state.isOptionDisabled',
- 'data-wp-bind--hidden' => 'hide' === $disabled_attributes_action ? 'state.isOptionDisabled' : null,
- 'data-wp-on--click' => 'actions.handlePillClick',
- 'data-wp-on--keydown' => 'actions.handleKeyDown',
- 'data-wp-context' => array(
- 'option' => $attribute_term,
- ),
- ),
- )
- );
-
- $pills .= '<label class="wc-block-add-to-cart-with-options-variation-selector-attribute-options__pill">' . $input . esc_html( $attribute_term['label'] ) . '</label>';
- }
-
- return sprintf(
- '<div %s>%s</div>',
- $this->get_normalized_attributes(
- array(
- 'class' => 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__pills',
- 'role' => 'radiogroup',
- 'id' => $attribute_id,
- 'aria-labelledby' => $attribute_id . '_label',
- 'data-wp-context' => array(
- 'name' => wc_attribute_label( $block->context['woocommerce/attributeName'] ),
- 'options' => $attribute_terms,
- 'selectedValue' => $this->get_default_selected_attribute( $attribute_slug, $attribute_terms ),
- 'focused' => '',
- 'autoselect' => $autoselect,
- ),
- 'data-wp-init' => 'callbacks.setDefaultSelectedAttribute',
- ),
- ),
- $pills,
- );
- }
-
- /**
- * Render the attribute options as a dropdown.
- *
- * @param array $attributes Block attributes.
- * @param string $content Block content.
- * @param \WP_Block $block Block instance.
- * @return string The dropdown.
- */
- protected function render_dropdown( $attributes, $content, $block ) {
- $attribute_id = $block->context['woocommerce/attributeId'];
- $attribute_slug = wc_variation_attribute_name( $block->context['woocommerce/attributeName'] );
- $attribute_terms = $block->context['woocommerce/attributeTerms'];
- $default_option = array(
- 'label' => esc_html__( 'Choose an option', 'woocommerce' ),
- 'value' => '',
- 'isSelected' => false,
- );
-
- $attribute_terms = array_merge(
- array( $default_option ),
- $attribute_terms
- );
-
- $selected_attribute = $this->get_default_selected_attribute( $attribute_slug, $attribute_terms );
- $autoselect = $attributes['autoselect'] ?? false;
- $disabled_attributes_action = $attributes['disabledAttributesAction'] ?? 'disable';
-
- $options = '';
- foreach ( $attribute_terms as $attribute_term ) {
- $option_attributes = array(
- 'value' => $attribute_term['value'],
- 'data-wp-bind--selected' => 'state.isOptionSelected',
- 'data-wp-bind--disabled' => 'state.isOptionDisabled',
- 'data-wp-bind--hidden' => 'hide' === $disabled_attributes_action ? 'state.isOptionDisabled' : null,
- 'data-wp-context' => array(
- 'option' => $attribute_term,
- ),
- );
-
- if ( $attribute_term['value'] === $selected_attribute ) {
- $option_attributes['selected'] = 'selected';
- }
-
- $options .= sprintf(
- '<option %s>%s</option>',
- $this->get_normalized_attributes(
- $option_attributes
- ),
- esc_html( $attribute_term['label'] )
- );
- }
-
- return sprintf(
- '<select %s>%s</select>',
- $this->get_normalized_attributes(
- array(
- 'class' => 'wc-block-add-to-cart-with-options-variation-selector-attribute-options__dropdown',
- 'id' => $attribute_id,
- 'data-wp-context' => array(
- 'name' => wc_attribute_label( $block->context['woocommerce/attributeName'] ),
- 'options' => $attribute_terms,
- 'selectedValue' => $selected_attribute,
- 'autoselect' => $autoselect,
- ),
- 'data-wp-init' => 'callbacks.setDefaultSelectedAttribute',
- 'data-wp-on--change' => 'actions.handleDropdownChange',
- 'name' => $attribute_slug,
- ),
- ),
- $options,
- );
- }
-}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php b/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php
new file mode 100644
index 00000000000..e0021e8e15d
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Dropdown.php
@@ -0,0 +1,148 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+/**
+ * Dropdown block (native select).
+ */
+final class Dropdown extends AbstractBlock {
+
+ use EnableBlockJsonAssetsTrait;
+
+ /**
+ * Block name.
+ *
+ * @var string
+ */
+ protected $block_name = 'dropdown';
+
+ /**
+ * Plain-text label for a select option (no HTML in `<option>`).
+ *
+ * @param array $item Selectable item from context.
+ * @return string
+ */
+ private function get_option_text( array $item ): string {
+ if ( isset( $item['label'] ) && is_string( $item['label'] ) ) {
+ return wp_strip_all_tags( $item['label'] );
+ }
+ if ( ! empty( $item['ariaLabel'] ) && is_string( $item['ariaLabel'] ) ) {
+ return $item['ariaLabel'];
+ }
+ return '';
+ }
+
+ /**
+ * Render the block.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block content.
+ * @param \WP_Block $block Block instance.
+ * @return string Rendered block type output.
+ */
+ protected function render( $attributes, $content, $block ) {
+ if ( empty( $block->context['woocommerce/selectableItems'] ) ) {
+ return '';
+ }
+
+ $selectable_items = $block->context['woocommerce/selectableItems'];
+ $items = is_array( $selectable_items['items'] ?? null ) ? $selectable_items['items'] : array();
+ $store_namespace = is_string( $selectable_items['storeNamespace'] ?? null ) ? $selectable_items['storeNamespace'] : 'woocommerce/add-to-cart-with-options';
+
+ if ( empty( $items ) ) {
+ return '';
+ }
+
+ $attribute_id = $block->context['woocommerce/attributeId'] ?? '';
+ $has_external_label = is_string( $attribute_id ) && '' !== $attribute_id;
+ $select_id = $has_external_label
+ ? $attribute_id
+ : wp_unique_id( 'wc-block-dropdown-' );
+
+ $wrapper_attributes = array(
+ 'data-wp-interactive' => 'woocommerce/dropdown',
+ 'data-wp-context' => (string) wp_json_encode(
+ array(
+ 'storeNamespace' => $store_namespace,
+ ),
+ JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
+ ),
+ );
+
+ $aria_label = isset( $selectable_items['groupLabel'] ) && is_string( $selectable_items['groupLabel'] ) ? $selectable_items['groupLabel'] : __( 'Choose an option', 'woocommerce' );
+
+ ob_start();
+ ?>
+ <div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
+ <fieldset class="wc-block-dropdown__fieldset">
+ <?php if ( ! $has_external_label ) : ?>
+ <legend class="screen-reader-text"><?php echo esc_html( __( 'Choose an option', 'woocommerce' ) ); ?></legend>
+ <?php endif; ?>
+ <select
+ class="wc-block-dropdown__select"
+ id="<?php echo esc_attr( $select_id ); ?>"
+ <?php if ( $has_external_label ) : ?>
+ aria-labelledby="<?php echo esc_attr( $attribute_id . '_label' ); ?>"
+ <?php else : ?>
+ aria-label="<?php echo esc_attr( $aria_label ); ?>"
+ <?php endif; ?>
+ data-wp-bind--value="state.selectValue"
+ data-wp-on--change="actions.onDropdownChange"
+ >
+ <option value="">
+ <?php echo esc_html( __( 'Choose an option', 'woocommerce' ) ); ?>
+ </option>
+ <?php foreach ( $items as $item ) : ?>
+ <?php
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+ $option_label = $this->get_option_text( $item );
+ if ( empty( $option_label ) ) {
+ continue;
+ }
+ $item_id = isset( $item['id'] ) && is_string( $item['id'] ) ? $item['id'] : '';
+ $item_value = isset( $item['value'] ) && is_string( $item['value'] ) ? $item['value'] : '';
+ ?>
+ <option
+ id="<?php echo esc_attr( $item_id ); ?>"
+ value="<?php echo esc_attr( $item_value ); ?>"
+ <?php disabled( ! empty( $item['disabled'] ) ); ?>
+ <?php
+ if ( ! empty( $item['hidden'] ) ) :
+ ?>
+ hidden
+ <?php
+ endif;
+ if ( ! empty( $item['selected'] ) ) :
+ ?>
+ selected
+ <?php
+ endif;
+ ?>
+ data-wp-each-child
+ >
+ <?php echo esc_html( $option_label ); ?>
+ </option>
+ <?php endforeach; ?>
+ <template
+ data-wp-interactive="<?php echo esc_attr( $store_namespace ); ?>"
+ data-wp-each--item="state.selectableItems"
+ data-wp-each-key="context.item.id"
+ >
+ <option
+ data-wp-bind--value="context.item.value"
+ data-wp-bind--disabled="context.item.disabled"
+ data-wp-bind--hidden="context.item.hidden"
+ data-wp-text="context.item.label"
+ ></option>
+ </template>
+ </select>
+ </fieldset>
+ </div>
+ <?php
+ $output = ob_get_clean();
+ return is_string( $output ) ? $output : '';
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
index bccb3fc60d0..de18788a701 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php
@@ -100,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( 'woocommerceRemovableItems' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/removableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index abc09e8c2a5..4613b625bb9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -298,7 +298,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( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/selectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
index 624bc998a54..d739edc81e2 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
@@ -33,11 +33,11 @@ final class ProductFilterCheckboxList extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
+ if ( empty( $block->context['woocommerce/selectableItems'] ) ) {
return '';
}
- $block_context = $block->context['woocommerceSelectableItems'];
+ $block_context = $block->context['woocommerce/selectableItems'];
$items = is_array( $block_context['items'] ?? null ) ? $block_context['items'] : array();
$store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
$filter_type = $block_context['filterType'] ?? '';
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index a15ac2d1fd8..81b7b8b5eca 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -17,30 +17,23 @@ 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.
*
- * @param array $attributes Block attributes.
- * @param string $content Block content.
- * @param WP_Block $block Block instance.
+ * @param array $attributes Block attributes.
+ * @param string $content Block content.
+ * @param \WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- if ( empty( $block->context['woocommerceSelectableItems'] ) ) {
+ if ( empty( $block->context['woocommerce/selectableItems'] ) ) {
return '';
}
- $block_context = $block->context['woocommerceSelectableItems'];
+ $block_context = $block->context['woocommerce/selectableItems'];
$items = is_array( $block_context['items'] ?? null ) ? $block_context['items'] : array();
$store_namespace = $block_context['storeNamespace'] ?? 'woocommerce/product-filters';
- $display_limit = self::DISPLAY_LIMIT;
+ $display_limit = 'woocommerce/product-filters' === $store_namespace ? 15 : 30;
$classes = '';
$style = '';
@@ -51,8 +44,9 @@ final class ProductFilterChips extends AbstractBlock {
}
$wrapper_attributes = array(
- 'data-wp-interactive' => 'woocommerce/product-filter-chips',
- 'data-wp-context' => (string) wp_json_encode(
+ 'data-wp-interactive' => 'woocommerce/product-filter-chips',
+ 'data-wp-init--colors' => 'callbacks.initColors',
+ 'data-wp-context' => (string) wp_json_encode(
array(
'storeNamespace' => $store_namespace,
'displayLimit' => $display_limit,
@@ -60,7 +54,7 @@ final class ProductFilterChips extends AbstractBlock {
),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
),
- 'class' => esc_attr( $classes ),
+ 'class' => esc_attr( $classes ),
);
if ( ! empty( $style ) ) {
@@ -68,6 +62,14 @@ final class ProductFilterChips extends AbstractBlock {
$wrapper_attributes['style'] = esc_attr( $style ) . ';';
}
+ $attribute_id = $block->context['woocommerce/attributeId'] ?? '';
+ $has_external_label = is_string( $attribute_id ) && '' !== $attribute_id;
+
+ if ( $has_external_label ) {
+ $wrapper_attributes['role'] = 'single' === $block_context['selectionMode'] ? 'radiogroup' : 'group';
+ $wrapper_attributes['aria-labelledby'] = esc_attr( $attribute_id . '_label' );
+ }
+
$first_items = array_slice( $items, 0, $display_limit, true );
$overflow_items = array_slice( $items, $display_limit );
$overflow_selected_items = array_filter( $overflow_items, fn( $item ) => is_array( $item ) && ! empty( $item['selected'] ) );
@@ -77,6 +79,7 @@ final class ProductFilterChips extends AbstractBlock {
$first_item = reset( $items );
$show_counts = is_array( $first_item ) && array_key_exists( 'count', $first_item );
$has_color_swatches = is_array( $first_item ) && array_key_exists( 'color', $first_item );
+ $button_role = 'single' === $block_context['selectionMode'] ? 'radio' : 'checkbox';
if ( $has_color_swatches && is_string( $classes ) && ! str_contains( $classes, 'is-style-swatch' ) ) {
$classes .= ' is-style-swatch';
@@ -86,8 +89,8 @@ final class ProductFilterChips extends AbstractBlock {
ob_start();
?>
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
- <fieldset>
- <?php if ( ! empty( $block_context['groupLabel'] ) ) : ?>
+ <fieldset class="wc-block-product-filter-chips__fieldset">
+ <?php if ( ! empty( $block_context['groupLabel'] ) && ! $has_external_label ) : ?>
<legend class="screen-reader-text"><?php echo esc_html( $block_context['groupLabel'] ); ?></legend>
<?php endif; ?>
<div class="wc-block-product-filter-chips__items">
@@ -97,7 +100,7 @@ final class ProductFilterChips extends AbstractBlock {
<button
class="wc-block-product-filter-chips__item"
type="button"
- role="checkbox"
+ role="<?php echo esc_attr( $button_role ); ?>"
id="<?php echo esc_attr( $item['id'] ); ?>"
<?php if ( ! empty( $item['ariaLabel'] ) ) : ?>
aria-label="<?php echo esc_attr( $item['ariaLabel'] ); ?>"
@@ -143,7 +146,7 @@ final class ProductFilterChips extends AbstractBlock {
<button
class="wc-block-product-filter-chips__item"
type="button"
- role="checkbox"
+ role="<?php echo esc_attr( $button_role ); ?>"
data-wp-bind--id="context.item.id"
data-wp-bind--aria-label="context.item.ariaLabel"
<?php if ( $has_color_swatches ) : ?>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
index d79c7171468..b0a53943269 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterClearButton.php
@@ -37,7 +37,7 @@ 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;
+ $removable_context = $block->context['woocommerce/removableItems'] ?? null;
if (
is_admin() ||
wp_doing_ajax() ||
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
index 5136b0b993b..7b31811cef7 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPrice.php
@@ -178,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( 'woocommerceRangeInput' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/rangeInput' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
index f0a2c944f7b..e200cf39d01 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterPriceSlider.php
@@ -32,11 +32,11 @@ 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['woocommerceRangeInput'] ) ) {
+ if ( is_admin() || wp_doing_ajax() || empty( $block->context['woocommerce/rangeInput'] ) ) {
return '';
}
- $range_data = $block->context['woocommerceRangeInput'];
+ $range_data = $block->context['woocommerce/rangeInput'];
$min_range = $range_data['min'] ?? 0;
$max_range = $range_data['max'] ?? 0;
$min_price = $range_data['currentMin'] ?? $min_range;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
index c28a20ac1b7..aeebec806dc 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRating.php
@@ -162,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( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/selectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterRemovableChips.php
index d27174ddf9c..1750c3d7263 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['woocommerceRemovableItems'] )
+ empty( $block->context['woocommerce/removableItems'] )
) {
return '';
}
- $filter_items = $block->context['woocommerceRemovableItems']['items'] ?? array();
+ $filter_items = $block->context['woocommerce/removableItems']['items'] ?? array();
$style = '';
@@ -72,8 +72,13 @@ final class ProductFilterRemovableChips extends AbstractBlock {
</li>
</template>
<?php foreach ( $filter_items as $item ) : ?>
- <?php // translators: %s: item label. ?>
- <?php $remove_label = sprintf( __( 'Remove filter: %s', 'woocommerce' ), $item['label'] ); ?>
+ <?php
+ $remove_label = sprintf(
+ /* translators: %s: item label. */
+ __( '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 ?>
>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
index 56e2488c49c..12bbc4edde4 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterStatus.php
@@ -164,7 +164,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( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/selectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index d569892218c..859beba25d3 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -301,7 +301,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( 'woocommerceSelectableItems' => $filter_context ) ) )->render();
+ $carry .= ( new \WP_Block( $parsed_block, array( 'woocommerce/selectableItems' => $filter_context ) ) )->render();
return $carry;
},
''
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index afd97ad90a5..046a9111098 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -381,6 +381,7 @@ final class BlockTypesController {
'CatalogSorting',
'ClassicShortcode',
'CustomerAccount',
+ 'Dropdown',
'FeaturedCategory',
'FeaturedProduct',
'MiniCart',
@@ -449,6 +450,7 @@ final class BlockTypesController {
'ComingSoon',
'CouponCode',
'CustomerAccount',
+ 'Dropdown',
'EmailContent',
'FeaturedCategory',
'FeaturedProduct',
@@ -560,7 +562,6 @@ final class BlockTypesController {
$block_types[] = 'AddToCartWithOptions\VariationSelector';
$block_types[] = 'AddToCartWithOptions\VariationSelectorAttribute';
$block_types[] = 'AddToCartWithOptions\VariationSelectorAttributeName';
- $block_types[] = 'AddToCartWithOptions\VariationSelectorAttributeOptions';
$block_types[] = 'AddToCartWithOptions\GroupedProductSelector';
$block_types[] = 'AddToCartWithOptions\GroupedProductItem';
$block_types[] = 'AddToCartWithOptions\GroupedProductItemSelector';
diff --git a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
index df818a2d28b..f9eb81d8eb0 100644
--- a/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
+++ b/plugins/woocommerce/templates/parts/variable-product-add-to-cart-with-options.html
@@ -15,7 +15,9 @@
>
<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-name /-->
- <!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options /-->
+ <!-- wp:woocommerce/product-filter-chips -->
+ <div class="wp-block-woocommerce-product-filter-chips wc-block-product-filter-chips"></div>
+ <!-- /wp:woocommerce/product-filter-chips -->
</div>
<!-- /wp:group -->
</div>
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index 79fcd36a01e..2ffa2160dcb 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -15,7 +15,6 @@ use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsGroupedProduct
use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorMock;
use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeMock;
use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeNameMock;
-use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeOptionsMock;
use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
/**
@@ -47,7 +46,6 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
new AddToCartWithOptionsVariationSelectorMock();
new AddToCartWithOptionsVariationSelectorAttributeMock();
new AddToCartWithOptionsVariationSelectorAttributeNameMock();
- new AddToCartWithOptionsVariationSelectorAttributeOptionsMock();
self::$are_blocks_registered = true;
}
@@ -471,54 +469,6 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
);
}
- /**
- * Tests that the VariationSelectorAttribute block returns empty string
- * for non-variable products (simple, grouped, external) to prevent
- * a fatal error from calling get_variation_attributes() on unsupported
- * product types.
- *
- * @covers VariationSelectorAttribute::render
- */
- public function test_variation_selector_attribute_returns_empty_for_non_variable_products() {
- global $product;
- $original_product = $product;
-
- $block_markup = '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute /-->';
-
- try {
- // Test with a missing/invalid product context.
- $product = null;
- $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string when the global product is null.' );
-
- $product = false;
- $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string when the global product is false.' );
-
- // Test with a simple product.
- $simple_product = new \WC_Product_Simple();
- $simple_product->set_regular_price( 10 );
- $simple_product->save();
-
- $product = $simple_product;
- $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for simple products.' );
-
- // Test with a grouped product.
- $grouped_product = new \WC_Product_Grouped();
- $grouped_product->save();
-
- $product = $grouped_product;
- $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for grouped products.' );
-
- // Test with an external product.
- $external_product = new \WC_Product_External();
- $external_product->save();
-
- $product = $external_product;
- $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for external products.' );
- } finally {
- $product = $original_product;
- }//end try
- }
-
/**
* Tests that the quantity selector and its steppers are hidden when
* a filter sets min and max quantity to the same value for a product.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
new file mode 100644
index 00000000000..c1a068206ca
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
@@ -0,0 +1,268 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeMock;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeNameMock;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the VariationSelectorAttribute block type.
+ */
+class VariationSelectorAttribute extends WC_Unit_Test_Case {
+
+ /**
+ * Tracks whether blocks have been registered.
+ *
+ * @var bool
+ */
+ protected static $are_blocks_registered = false;
+
+ /**
+ * Register blocks required for do_blocks tests.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ if ( ! self::$are_blocks_registered ) {
+ new AddToCartWithOptionsVariationSelectorAttributeMock();
+ new AddToCartWithOptionsVariationSelectorAttributeNameMock();
+
+ self::$are_blocks_registered = true;
+ }
+ }
+
+ /**
+ * Data provider for legacy Attribute Options block replacement styles.
+ *
+ * @return array<string, array{0: array<string, string>, 1: string, 2: string}>
+ */
+ public function legacy_attribute_options_block_styles_provider(): array {
+ return array(
+ 'dropdown' => array(
+ array( 'optionStyle' => 'dropdown' ),
+ 'wc-block-dropdown',
+ 'wc-block-product-filter-chips',
+ ),
+ 'pills' => array(
+ array( 'optionStyle' => 'pills' ),
+ 'wc-block-product-filter-chips',
+ 'wc-block-dropdown',
+ ),
+ 'default' => array(
+ array(),
+ 'wc-block-product-filter-chips',
+ 'wc-block-dropdown',
+ ),
+ );
+ }
+
+ /**
+ * Tests that the block returns empty string for non-variable products.
+ *
+ * @testdox VariationSelectorAttribute returns an empty string for non-variable products.
+ * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::render
+ */
+ public function test_returns_empty_for_non_variable_products(): void {
+ global $product;
+ $original_product = $product;
+
+ $block_markup = '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute /-->';
+
+ try {
+ $product = null;
+ $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string when the global product is null.' );
+
+ $product = false;
+ $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string when the global product is false.' );
+
+ $simple_product = new \WC_Product_Simple();
+ $simple_product->set_regular_price( 10 );
+ $simple_product->save();
+
+ $product = $simple_product;
+ $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for simple products.' );
+
+ $grouped_product = new \WC_Product_Grouped();
+ $grouped_product->save();
+
+ $product = $grouped_product;
+ $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for grouped products.' );
+
+ $external_product = new \WC_Product_External();
+ $external_product->save();
+
+ $product = $external_product;
+ $this->assertSame( '', do_blocks( $block_markup ), 'VariationSelectorAttribute should return empty string for external products.' );
+ } finally {
+ $product = $original_product;
+ }
+ }
+
+ /**
+ * Tests that legacy Attribute Options blocks are replaced with dropdown or chips blocks when rendered.
+ *
+ * @testdox Legacy Attribute Options block renders with %2$s and not %3$s.
+ * @dataProvider legacy_attribute_options_block_styles_provider
+ * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::replace_legacy_attribute_options_block
+ *
+ * @param array $options_attrs Legacy options block attributes.
+ * @param string $expected_output_class CSS class expected in rendered output.
+ * @param string $unexpected_output_class CSS class that should not appear in rendered output.
+ */
+ public function test_replaces_legacy_attribute_options_block_when_rendered( array $options_attrs, string $expected_output_class, string $unexpected_output_class ): void {
+ $variable_product = $this->create_variable_product_with_variations();
+ $inner_blocks = $this->get_attribute_name_block_markup() . $this->get_legacy_options_block_markup( $options_attrs );
+
+ $markup = $this->render_variation_selector_attribute( $variable_product, $inner_blocks );
+
+ $this->assertStringContainsString( 'variation-selector-attribute-name', $markup, 'Attribute name block should render.' );
+ $this->assertStringContainsString( $expected_output_class, $markup, 'Legacy Attribute Options block should render as the replacement block.' );
+ $this->assertStringNotContainsString( $unexpected_output_class, $markup, 'Legacy Attribute Options block should not render as the other option style.' );
+ }
+
+ /**
+ * Tests that autoselect and disabledAttributesAction are migrated from legacy Attribute Options blocks.
+ *
+ * @testdox autoselect and disabledAttributesAction are migrated from legacy Attribute Options block.
+ * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::replace_legacy_attribute_options_block
+ */
+ public function test_migrates_legacy_attribute_options_settings_when_rendered(): void {
+ $variable_product = $this->create_variable_product_with_variations();
+ $inner_blocks = $this->get_attribute_name_block_markup() . $this->get_legacy_options_block_markup(
+ array(
+ 'autoselect' => true,
+ 'disabledAttributesAction' => 'hide',
+ )
+ );
+
+ $markup = $this->render_variation_selector_attribute( $variable_product, $inner_blocks );
+
+ $this->assertStringContainsString( '"autoselect":true', $markup, 'Legacy autoselect should be applied to the interactivity context.' );
+ $this->assertStringContainsString( '"disabledAttributesAction":"hide"', $markup, 'Legacy disabledAttributesAction should be applied to the interactivity context.' );
+ }
+
+ /**
+ * Tests that legacy Attribute Options blocks nested in a group are replaced when rendered.
+ *
+ * @testdox Legacy Attribute Options block nested in a group is replaced with a dropdown when rendered.
+ * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::replace_legacy_attribute_options_block
+ */
+ public function test_replaces_nested_legacy_attribute_options_block_when_rendered(): void {
+ $variable_product = $this->create_variable_product_with_variations();
+ $inner_blocks = sprintf(
+ '<!-- wp:group -->%s%s<!-- /wp:group -->',
+ $this->get_attribute_name_block_markup(),
+ $this->get_legacy_options_block_markup( array( 'optionStyle' => 'dropdown' ) )
+ );
+
+ $markup = $this->render_variation_selector_attribute( $variable_product, $inner_blocks );
+
+ $this->assertStringContainsString( 'variation-selector-attribute-name', $markup, 'Attribute name block should render.' );
+ $this->assertStringContainsString( 'wc-block-dropdown', $markup, 'Nested legacy Attribute Options block should render as a dropdown.' );
+ $this->assertStringNotContainsString( 'wc-block-product-filter-chips', $markup, 'Nested legacy Attribute Options block should not render as chips.' );
+ }
+
+ /**
+ * Create a variable product with color variations for rendering tests.
+ *
+ * @return \WC_Product_Variable
+ */
+ private function create_variable_product_with_variations(): \WC_Product_Variable {
+ $fixtures = new FixtureData();
+
+ $product = $fixtures->get_variable_product(
+ array(),
+ array(
+ $fixtures->get_product_attribute( 'color', array( 'red', 'blue' ) ),
+ )
+ );
+
+ $product_id = $product->get_id();
+
+ $fixtures->get_variation_product(
+ $product_id,
+ array(
+ 'pa_color' => 'red-slug',
+ ),
+ array(
+ 'regular_price' => 10,
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $fixtures->get_variation_product(
+ $product_id,
+ array(
+ 'pa_color' => 'blue-slug',
+ ),
+ array(
+ 'regular_price' => 10,
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ \WC_Product_Variable::sync( $product_id );
+
+ $variable_product = wc_get_product( $product_id );
+
+ $this->assertInstanceOf( \WC_Product_Variable::class, $variable_product );
+
+ return $variable_product;
+ }
+
+ /**
+ * Render the variation selector attribute block with the given inner block markup.
+ *
+ * @param \WC_Product_Variable $variable_product Variable product to use as context.
+ * @param string $inner_blocks_markup Inner blocks markup.
+ * @return string Rendered block output.
+ */
+ private function render_variation_selector_attribute( \WC_Product_Variable $variable_product, string $inner_blocks_markup ): string {
+ global $product;
+
+ $original_product = $product;
+ $product = $variable_product;
+
+ $block_markup = sprintf(
+ '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute -->%s<!-- /wp:woocommerce/add-to-cart-with-options-variation-selector-attribute -->',
+ $inner_blocks_markup
+ );
+
+ try {
+ return do_blocks( $block_markup );
+ } finally {
+ $product = $original_product;
+ }
+ }
+
+ /**
+ * Get block markup for the attribute name inner block.
+ *
+ * @return string
+ */
+ private function get_attribute_name_block_markup(): string {
+ return '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-name /-->';
+ }
+
+ /**
+ * Get block markup for the legacy Attribute Options inner block.
+ *
+ * @param array $attrs Legacy options block attributes.
+ * @return string
+ */
+ private function get_legacy_options_block_markup( array $attrs ): string {
+ if ( empty( $attrs ) ) {
+ return '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options /-->';
+ }
+
+ return sprintf(
+ '<!-- wp:woocommerce/add-to-cart-with-options-variation-selector-attribute-options %s /-->',
+ wp_json_encode( $attrs )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsVariationSelectorAttributeOptionsMock.php b/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsVariationSelectorAttributeOptionsMock.php
deleted file mode 100644
index 24e55bafdb6..00000000000
--- a/plugins/woocommerce/tests/php/src/Blocks/Mocks/AddToCartWithOptionsVariationSelectorAttributeOptionsMock.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Blocks\Mocks;
-
-use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttributeOptions;
-use Automattic\WooCommerce\Blocks\Package;
-use Automattic\WooCommerce\Blocks\Assets\Api;
-use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
-use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
-
-/**
- * AddToCartWithOptionsVariationSelectorAttributeOptionsMock used to test VariationSelectorAttributeOptions block functions.
- */
-class AddToCartWithOptionsVariationSelectorAttributeOptionsMock extends VariationSelectorAttributeOptions {
- /**
- * Initialize our mock class.
- */
- public function __construct() {
- parent::__construct(
- Package::container()->get( Api::class ),
- Package::container()->get( AssetDataRegistry::class ),
- new IntegrationRegistry(),
- );
- }
-}