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();
 	}