Commit d9ce875c37 for woocommerce
commit d9ce875c37e1dd80185328f214ca69504bb466ea
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Tue Jan 27 16:23:32 2026 +0000
Make variation and itemData show inline with separators on Cart/Checkout block (#62897)
* Make variation and itemData show inline with separators
* Update styles for itemData and variation data to show inline
* Remove bold from itemData and variation data attribute names
* Add changelog
* Hide separators from screen readers
* Update tests to account for new layout using div/span not li
* Lint fix
* Update mini-cart to display attributes/data in line with separators
* Update claude with learnings about interactivit private stores
diff --git a/CLAUDE.md b/CLAUDE.md
index 025de66d87..e3fb08b0a3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -116,6 +116,18 @@ For detailed test commands, see `woocommerce-dev-cycle` skill.
- Never create standalone functions (always use class methods)
- Tests require Docker environment
+## Interactivity API Stores
+
+All WooCommerce Interactivity API stores are **private by design**:
+
+- Stores use `lock: true` indicating they are not intended for extension
+- Removing or changing store state/selectors is **not a breaking change**
+- No backwards compatibility is required for store internals
+- If a store needs to be extensible in the future, it will be split into private (internal) and public (API) stores
+- General stores (namespace `woocommerce`) may become public eventually, but currently all are locked
+
+Reference: [WordPress Interactivity API - Private Stores](https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/api-reference#private-stores)
+
## Quick Reference
### Most Common Commands
diff --git a/plugins/woocommerce/changelog/wooprd-1446-consolidate-variable-product-attributes b/plugins/woocommerce/changelog/wooprd-1446-consolidate-variable-product-attributes
new file mode 100644
index 0000000000..a27b99e5e8
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-1446-consolidate-variable-product-attributes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Item data and variation data now show inline on the Cart and Checkout blocks
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/index.tsx
index 918ab3f5e1..17296e4a2c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/index.tsx
@@ -35,23 +35,15 @@ const ProductDetails = ( {
return null;
}
- details = details.filter( ( detail ) => ! detail.hidden );
+ const filteredDetails = details.filter( ( detail ) => ! detail.hidden );
- if ( details.length === 0 ) {
+ if ( filteredDetails.length === 0 ) {
return null;
}
- let ParentTag = 'ul' as keyof JSX.IntrinsicElements;
- let ChildTag = 'li' as keyof JSX.IntrinsicElements;
-
- if ( details.length === 1 ) {
- ParentTag = 'div';
- ChildTag = 'div';
- }
-
return (
- <ParentTag className="wc-block-components-product-details">
- { details.map( ( detail ) => {
+ <div className="wc-block-components-product-details">
+ { filteredDetails.map( ( detail, index ) => {
// Support both `key` and `name` props
const name = detail?.key || detail.name || '';
// Strip HTML tags from name for CSS class generation
@@ -67,8 +59,10 @@ const ProductDetails = ( {
) }`
: '' );
+ const isLast = index === filteredDetails.length - 1;
+
return (
- <ChildTag
+ <span
key={ name + ( detail.display || detail.value ) }
className={ className }
>
@@ -98,10 +92,11 @@ const ProductDetails = ( {
),
} }
/>
- </ChildTag>
+ { ! isLast && <span aria-hidden="true"> / </span> }
+ </span>
);
} ) }
- </ParentTag>
+ </div>
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/style.scss
index 00fd5ddff7..600944e6e7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/style.scss
@@ -1,28 +1,15 @@
// Extra class added for specificity so styles are applied in the editor.
.wc-block-components-product-details.wc-block-components-product-details {
@include font-x-small-locked;
- list-style: none;
margin: 0.5em 0;
- padding: 0;
&:last-of-type {
margin-bottom: 0;
}
-
- li {
- margin-left: 0;
- }
}
.wc-block-components-product-details__name,
.wc-block-components-product-details__value {
- display: inline-block;
+ display: inline;
}
-@include cart-checkout-large-container {
- .wc-block-cart__main {
- .wc-block-components-product-details__name {
- font-weight: bold;
- }
- }
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/test/index.js b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/test/index.js
index e14bbe668e..18a8a7745e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/test/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/product-details/test/index.js
@@ -18,15 +18,17 @@ describe( 'ProductDetails', () => {
const { container } = render( <ProductDetails details={ details } /> );
- // Should render as ul since there are multiple details
- const list = container.querySelector(
- 'ul.wc-block-components-product-details'
+ // Should render as div
+ const wrapper = container.querySelector(
+ 'div.wc-block-components-product-details'
);
- expect( list ).toBeInTheDocument();
+ expect( wrapper ).toBeInTheDocument();
- // Should have 3 list items
- const listItems = container.querySelectorAll( 'li' );
- expect( listItems ).toHaveLength( 3 );
+ // Should have 3 span items
+ const items = container.querySelectorAll(
+ '.wc-block-components-product-details > span'
+ );
+ expect( items ).toHaveLength( 3 );
// First item should have name and value
expect( screen.getByText( 'Lorem:' ) ).toBeInTheDocument();
@@ -37,7 +39,7 @@ describe( 'ProductDetails', () => {
expect( screen.getByText( 'IPSUM' ) ).toBeInTheDocument();
// Third item should only have value (no name)
- const thirdItem = listItems[ 2 ];
+ const thirdItem = items[ 2 ];
expect(
thirdItem.querySelector(
'.wc-block-components-product-details__name'
@@ -59,18 +61,20 @@ describe( 'ProductDetails', () => {
const { container } = render( <ProductDetails details={ details } /> );
- // Should render as ul since there are multiple visible details
- const list = container.querySelector(
- 'ul.wc-block-components-product-details'
+ // Should render as div
+ const wrapper = container.querySelector(
+ 'div.wc-block-components-product-details'
);
- expect( list ).toBeInTheDocument();
+ expect( wrapper ).toBeInTheDocument();
// Should only have 2 items (hidden one filtered out)
- const listItems = container.querySelectorAll( 'li' );
- expect( listItems ).toHaveLength( 2 );
+ const items = container.querySelectorAll(
+ '.wc-block-components-product-details > span'
+ );
+ expect( items ).toHaveLength( 2 );
// Hidden item should not be rendered
- expect( screen.queryByText( 'Lorem' ) ).not.toBeInTheDocument();
+ expect( screen.queryByText( 'Lorem:' ) ).not.toBeInTheDocument();
// Visible items should be rendered
expect( screen.getByText( 'LOREM:' ) ).toBeInTheDocument();
@@ -106,33 +110,53 @@ describe( 'ProductDetails', () => {
expect( container.firstChild ).toBeNull();
} );
- test( 'should not render list if there is only one detail', () => {
- const details = [ { name: 'LOREM', value: 'Ipsum', display: 'IPSUM' } ];
+ test( 'should render separators between multiple details', () => {
+ const details = [
+ { name: 'Color', value: 'Red' },
+ { name: 'Size', value: 'Large' },
+ { name: 'Material', value: 'Cotton' },
+ ];
const { container } = render( <ProductDetails details={ details } /> );
- // Should render as div (not ul) since there's only one detail
- const div = container.querySelector(
+ const wrapper = container.querySelector(
'div.wc-block-components-product-details'
);
- expect( div ).toBeInTheDocument();
- // Should not render as ul
- const list = container.querySelector(
- 'ul.wc-block-components-product-details'
+ // Should have separators between items but not after last
+ expect( wrapper.textContent ).toBe(
+ 'Color: Red / Size: Large / Material: Cotton'
+ );
+
+ // Separators should be hidden from screen readers
+ const separators = container.querySelectorAll( '[aria-hidden="true"]' );
+ expect( separators ).toHaveLength( 2 );
+ } );
+
+ test( 'should render single detail without separator', () => {
+ const details = [ { name: 'LOREM', value: 'Ipsum', display: 'IPSUM' } ];
+
+ const { container } = render( <ProductDetails details={ details } /> );
+
+ // Should render as div
+ const wrapper = container.querySelector(
+ 'div.wc-block-components-product-details'
);
- expect( list ).not.toBeInTheDocument();
+ expect( wrapper ).toBeInTheDocument();
- // Should have one child div
- const childDivs = container.querySelectorAll(
- 'div.wc-block-components-product-details > div'
+ // Should have one span item
+ const items = container.querySelectorAll(
+ '.wc-block-components-product-details > span'
);
- expect( childDivs ).toHaveLength( 1 );
+ expect( items ).toHaveLength( 1 );
// Should contain name and value
expect( screen.getByText( 'LOREM:' ) ).toBeInTheDocument();
expect( screen.getByText( 'IPSUM' ) ).toBeInTheDocument();
+ // Should not have separator (single item)
+ expect( items[ 0 ].textContent ).toBe( 'LOREM: IPSUM' );
+
// Should have proper CSS classes
expect(
container.querySelector(
@@ -154,9 +178,12 @@ describe( 'ProductDetails', () => {
const { container } = render( <ProductDetails details={ details } /> );
- const listItems = container.querySelectorAll( 'li' );
- expect( listItems[ 0 ].textContent ).toBe( 'Color: Red' );
- expect( listItems[ 1 ].textContent ).toBe( 'Size: L' );
+ const items = container.querySelectorAll(
+ '.wc-block-components-product-details > span'
+ );
+ // First item has separator, last item does not
+ expect( items[ 0 ].textContent ).toBe( 'Color: Red / ' );
+ expect( items[ 1 ].textContent ).toBe( 'Size: L' );
} );
test( 'should apply correct CSS classes', () => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
index b5b89952da..e804695bd3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/iapi-frontend.ts
@@ -853,32 +853,42 @@ const { state: cartItemState } = store(
return `${ name }:${ value }`;
},
- get itemDataHasMultipleAttributes(): boolean {
+ get shouldHideProductDetails(): boolean {
const { dataProperty } = getContext< {
dataProperty: DataProperty;
} >();
- return cartItemState.cartItem[ dataProperty ]?.length > 1;
+ return cartItemState.cartItem[ dataProperty ].length === 0;
},
- get shouldHideProductDetails(): boolean {
- const { dataProperty } = getContext< {
+ get isLastCartItemDataAttr(): boolean {
+ const { itemData, dataProperty } = getContext< {
+ itemData: ItemData;
dataProperty: DataProperty;
} >();
- return cartItemState.cartItem[ dataProperty ].length === 0;
- },
- get shouldHideSingleProductDetails(): boolean {
- return (
- cartItemState.shouldHideProductDetails ||
- cartItemState.itemDataHasMultipleAttributes
- );
- },
+ const items = cartItemState.cartItem[ dataProperty ];
+ if ( ! items || items.length === 0 ) {
+ return true;
+ }
- get shouldHideMultipleProductDetails(): boolean {
- return (
- cartItemState.shouldHideProductDetails ||
- ! cartItemState.itemDataHasMultipleAttributes
+ // Filter out hidden items
+ const visibleItems = items.filter(
+ ( item: ItemData ) =>
+ ! (
+ item.hidden === true ||
+ item.hidden === 'true' ||
+ item.hidden === '1' ||
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ( item.hidden as any ) === 1
+ )
);
+
+ if ( visibleItems.length === 0 ) {
+ return true;
+ }
+
+ const lastItem = visibleItems[ visibleItems.length - 1 ];
+ return itemData === lastItem;
},
},
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
index e1a1405989..49cb18afe6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCartProductsTableBlock.php
@@ -275,22 +275,15 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
<div
<?php echo wp_interactivity_data_wp_context( $context ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
class="wc-block-components-product-details"
- data-wp-bind--hidden="state.shouldHideSingleProductDetails"
- >
- <?php echo $this->render_product_details_item_markup( 'div', $is_item_data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- </div>
- <ul
- <?php echo wp_interactivity_data_wp_context( $context ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- class="wc-block-components-product-details"
- data-wp-bind--hidden="state.shouldHideMultipleProductDetails"
+ data-wp-bind--hidden="state.shouldHideProductDetails"
>
<template
data-wp-each--item-data="state.cartItem.<?php echo esc_attr( $property ); ?>"
data-wp-each-key="state.cartItemDataKey"
>
- <?php echo $this->render_product_details_item_markup( 'li', $is_item_data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo $this->render_product_details_item_markup( $is_item_data ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</template>
- </ul>
+ </div>
<?php
return ob_get_clean();
}
@@ -298,25 +291,25 @@ class MiniCartProductsTableBlock extends AbstractInnerBlock {
/**
* Render markup for a single product detail item.
*
- * @param string $tag_name The HTML tag to use for the item.
- * @param bool $is_item_data Whether the item is of item_data type.
+ * @param bool $is_item_data Whether the item is of item_data type.
* @return string Rendered product detail item output based on item type.
*/
- private function render_product_details_item_markup( $tag_name, $is_item_data = false ) {
+ private function render_product_details_item_markup( $is_item_data = false ) {
ob_start();
?>
- <<?php echo tag_escape( $tag_name ); ?>
- data-wp-bind--hidden="state.cartItemDataAttrHidden"
- data-wp-bind--class="state.cartItemDataAttr.className"
- >
+ <span
+ data-wp-bind--hidden="state.cartItemDataAttrHidden"
+ data-wp-bind--class="state.cartItemDataAttr.className"
+ >
<?php if ( $is_item_data ) : ?>
<span class="wc-block-components-product-details__name" data-wp-watch="callbacks.itemDataNameInnerHTML"></span>
<span class="wc-block-components-product-details__value" data-wp-watch="callbacks.itemDataValueInnerHTML"></span>
<?php else : ?>
- <span class="wc-block-components-product-details__name" data-wp-text="state.cartItemDataAttr.name"></span>
+ <span class="wc-block-components-product-details__name" data-wp-text="state.cartItemDataAttr.name"></span>
<span class="wc-block-components-product-details__value" data-wp-text="state.cartItemDataAttr.value"></span>
<?php endif; ?>
- </<?php echo tag_escape( $tag_name ); ?>>
+ <span aria-hidden="true" data-wp-bind--hidden="state.isLastCartItemDataAttr"> / </span>
+ </span>
<?php
return ob_get_clean();
}