[cdx-436]: add-product-swatches-for-product-card - #52
Conversation
4950df9 to
514431a
Compare
There was a problem hiding this comment.
Pull request overview
Adds product swatches (color/variant selectors) to the ProductCard component, including optional “View more” truncation/expansion behavior, with Storybook docs/examples and test coverage.
Changes:
- Introduces swatch-related types and a new
useProductSwatchhook to manage selection + truncation/expansion state. - Updates
ProductCardto render aSwatchSection, apply selected-swatch overrides to displayed product data, and emit events/callbacks using the displayed variant. - Adds Storybook stories/docs and unit/component tests for swatch behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/styleHelpers.ts | Adds isHexColor helper used to decide hex-vs-image swatch previews. |
| src/types/productCardTypes.ts | Adds swatch types and new ProductCard props/override hooks for swatches. |
| src/hooks/useProductSwatch.ts | New hook for swatch selection and “view more” expansion logic. |
| src/components/product-card.tsx | Renders swatches, computes displayProduct, and wires “view more” behavior + overrides. |
| src/stories/components/ProductCard/UsagePatterns.mdx | Documents new swatch usage patterns in Storybook. |
| src/stories/components/ProductCard/ProductCard.stories.tsx | Adds swatch-focused stories (basic, image swatches, view-more). |
| src/stories/components/ProductCard/Code Examples - Swatches.mdx | New swatch code example documentation page. |
| src/stories/components/ProductCard/Code Examples - Compound Components.mdx | Documents new ProductCard.SwatchSection compound component. |
| spec/hooks/useProductSwatch.test.ts | Adds unit tests for hook selection/truncation/expansion. |
| spec/components/product-card/product-card.test.tsx | Adds component tests validating UI, data overrides, and view-more behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { useMemo, useState, useCallback } from 'react'; | ||
| import type { Product, SwatchItem, ProductSwatchObject } from '@/types/productCardTypes'; | ||
|
|
||
| export function useProductSwatch( | ||
| { swatchList = [], variationId }: Product, | ||
| maxSwatches?: number, | ||
| ): ProductSwatchObject { | ||
| const [selectedSwatch, setSelectedSwatch] = useState<SwatchItem | undefined>(() => | ||
| swatchList.find((swatch) => swatch.variationId === variationId), | ||
| ); | ||
| const [isExpanded, setIsExpanded] = useState(false); | ||
|
|
||
| const onSwatchClick = useCallback((swatch: SwatchItem) => { | ||
| setSelectedSwatch((selectedSwatch) => { | ||
| if (selectedSwatch?.variationId === swatch.variationId) return undefined; | ||
| else return swatch; | ||
| }); | ||
| }, []); | ||
|
|
||
| const onViewMoreSwatchesClick = useCallback(() => { | ||
| setIsExpanded(true); | ||
| }, []); | ||
|
|
| description?: ComponentOverrideProps<ProductCardProps>; | ||
| rating?: ComponentOverrideProps<ProductCardProps>; | ||
| price?: ComponentOverrideProps<ProductCardProps>; | ||
| swatches?: ComponentOverrideProps<ProductCardProps>; |
| className?: string; | ||
| } | ||
|
|
||
| export interface SwatchSectionProps extends IncludeRenderProps<ProductCardProps> { |
Alexey-Pavlov
left a comment
There was a problem hiding this comment.
@niizom Thanks for working on this! Left a few comments there and there. Could you please check when you get a chance?
| const isSelected = swatchItem.variationId === selectedSwatch?.variationId; | ||
| const bgValue = isHexColor(swatchItem.swatchPreview) | ||
| ? swatchItem.swatchPreview | ||
| : `url(${swatchItem.swatchPreview}) center/cover`; |
There was a problem hiding this comment.
The raw url(${swatchPreview}) broke the background shorthand when the URL contained spaces or parens. Quote-wrapping should fix that — inside a CSS string, spaces/parens are already literal — so we only need to escape the two chars that could break out of the quotes (" and \). Does this look right?
| : `url(${swatchItem.swatchPreview}) center/cover`; | |
| : `url("${swatchItem.swatchPreview.replace(/["\\]/g, '\\$&')}") center/cover`; |
| data-testid={`cio-swatch-${swatchItem.variationId}`} | ||
| data-cnstrc-item-variation-id={swatchItem.variationId} | ||
| className={cn( | ||
| 'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer p-0', |
There was a problem hiding this comment.
| 'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer p-0', | |
| 'cio-swatch-item cio:size-[25px] cio:rounded-full cio:border cio:border-black cio:cursor-pointer cio:p-0', |
| type='button' | ||
| data-testid='cio-swatch-show-more' | ||
| className='cio-swatch-show-more cio:bg-transparent cio:border-0 cio:p-0 cio:text-xs cio:underline cio:cursor-pointer cio:text-[var(--cio-swatch-more-color,#333)] cio:hover:text-[var(--cio-swatch-more-hover-color,#000)]' | ||
| onClick={(e) => onViewMoreSwatchesClick?.(e, selectedSwatch)} |
There was a problem hiding this comment.
| onClick={(e) => onViewMoreSwatchesClick?.(e, selectedSwatch)} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onViewMoreSwatchesClick?.(e, selectedSwatch); | |
| }} |
Alexey-Pavlov
left a comment
There was a problem hiding this comment.
LGTM! Thanks for working on this!
6e74376 to
f96c97f
Compare
There was a problem hiding this comment.
Code Review
This PR adds color/variant swatch selection to ProductCard with a clean hook-based design, compound component support, and good test coverage — overall a solid implementation with a few issues to address.
Inline comments: 7 discussions added
Overall Assessment:
| ); | ||
| const [isExpanded, setIsExpanded] = useState(false); | ||
|
|
||
| const productKey = JSON.stringify([id, variationId ?? null]); |
There was a problem hiding this comment.
Important Issue: Using JSON.stringify to derive a product identity key is fragile and has a hidden cost. If id is ever undefined (e.g. a product object constructed without one), the key becomes "[null,null]" and every product change will still be detected, but two truly different products with the same id/variationId pair will be treated as the same. More importantly, calling JSON.stringify on every render and then triggering three set* calls inside the render body (lines 16–18) is a pattern that requires care — React's rules allow it only when the previous state is set synchronously and the sets are guarded by a condition (the "derived state from props" pattern), but it forces an extra render cycle on every product change. A useEffect with [id, variationId] deps is the idiomatic approach here and avoids the extra render:
useEffect(() => {
setSelectedSwatch(swatchList.find((s) => s.variationId === variationId));
setIsExpanded(false);
}, [id, variationId]); // swatchList intentionally omitted — reset only on product identity change| props.showViewMoreSwatches ? props.maxSwatches : undefined, | ||
| ); | ||
| const displayProduct = useMemo(() => { | ||
| const filtered = Object.fromEntries( |
There was a problem hiding this comment.
Important Issue: The merge logic that builds displayProduct is lossy for falsy-but-valid values. For example, if a swatch explicitly sets price: 0 or salePrice: undefined to clear a sale price, those would be filtered out by v !== undefined, and then the base product's salePrice would remain visible. This silently produces incorrect pricing data. The filter should only remove undefined (which it does), but crucially the entire swatch.selectedSwatch object (including fields that are undefined) is spread after filtering — meaning a swatch that intentionally clears salePrice by leaving it undefined on SwatchItem will not override the base product's salePrice. Consider using a whitelist of known overridable fields and spreading only those:
const SWATCH_OVERRIDE_FIELDS = ['name','imageUrl','price','salePrice','badge','rating','reviewsCount','tags','description'] as const;
const displayProduct = useMemo(() => {
if (!swatch.selectedSwatch) return props.product;
const overrides = Object.fromEntries(
SWATCH_OVERRIDE_FIELDS
.filter((k) => swatch.selectedSwatch![k] !== undefined)
.map((k) => [k, swatch.selectedSwatch![k]])
);
return { ...props.product, ...overrides };
}, [props.product, swatch.selectedSwatch]);This also avoids accidentally copying internal swatch-only fields (like swatchPreview) onto displayProduct.
| onViewMoreSwatchesClick?.(e, selected); | ||
| }, | ||
| [product, onProductClick], | ||
| [expandInline, swatch.onViewMoreSwatchesClick, onViewMoreSwatchesClick], |
There was a problem hiding this comment.
Important Issue: swatch.onViewMoreSwatchesClick is already a stable useCallback(() => { setIsExpanded(true); }, []) — but swatch itself is a new object on every render (see the context memo issue above), so swatch.onViewMoreSwatchesClick as a dep array entry is unnecessarily volatile. This will cause handleViewMoreSwatchesClick to be recreated on every render. Destructure the stable callback before the useCallback:
const { onViewMoreSwatchesClick: expandSwatches } = swatch;
const handleViewMoreSwatchesClick = useCallback(
(e: React.MouseEvent, selected: SwatchItem | undefined) => {
if (expandInline) expandSwatches();
onViewMoreSwatchesClick?.(e, selected);
},
[expandInline, expandSwatches, onViewMoreSwatchesClick],
);| 'cio:outline-3 cio:outline-offset-[4px] cio:outline-current cio:opacity-60', | ||
| )} | ||
| style={{ background: bgValue }} | ||
| onClick={(e) => { |
There was a problem hiding this comment.
Suggestion: The inline arrow function onClick={(e) => { e.stopPropagation(); onSwatchClick?.(e, swatchItem); }} is recreated for every swatch on every render. Since swatchList.map already recreates the JSX, this is acceptable, but for consistency with the rest of the component's useCallback pattern, consider extracting this into a small event handler — or at minimum note that this is intentional.
|
|
||
| export interface SwatchItem { | ||
| variationId: string; | ||
| swatchPreview: string; |
There was a problem hiding this comment.
Suggestion: swatchPreview is marked as required on SwatchItem, but it is the only required field besides variationId. The test at spec/components/product-card/product-card.test.tsx already demonstrates a swatch without imageUrl/price etc., but the UI code in SwatchSection will call swatchItem.swatchPreview.replace(...) (line ~408 in product-card.tsx) without a null-guard. If a consumer passes a swatch without swatchPreview (e.g. via a cast or a data source that does not guarantee the field), this will throw at runtime. Add a guard:
const bgValue = swatchItem.swatchPreview
? isHexColor(swatchItem.swatchPreview)
? swatchItem.swatchPreview
: `url("${swatchItem.swatchPreview.replace(/["\\]/g, '\\$&')}") center/cover`
: 'transparent';|
|
||
| {/* Footer Section */} | ||
| {(onAddToCart || product.tags) && ( | ||
| {(onAddToCart || displayProduct.tags) && ( |
There was a problem hiding this comment.
Suggestion: The footer visibility condition uses displayProduct.tags (which could now change with swatch selection), but this was product.tags before this PR. This is the correct behavior — swatch-level tags should show/hide the footer — but it is worth verifying that the existing tests for the footer cover the case where the base product has no onAddToCart and no tags, but a selected swatch adds tags. If not, a test for this scenario would prevent regressions.
Pull Request Checklist
Before you submit a pull request, please make sure you have to following:
PR Type
What kind of change does this PR introduce?