[CDX-436] add product swatch for product card#45
Conversation
There was a problem hiding this comment.
Pull request overview
Adds product swatches to the ProductCard so users can switch variants (name/image/price/variationId) directly from the card.
Changes:
- Introduces swatch types and a
useProductSwatchhook to manage swatch list + selection state. - Updates
ProductCardto render a newSwatchSectionand to derive a “display product” from the selected swatch. - Adds Storybook stories and unit tests covering swatch rendering and interaction.
Reviewed changes
Copilot reviewed 9 out of 9 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 between color vs image swatch rendering. |
src/utils/productDisplay.ts |
Adds getDisplayProduct helper to compute the displayed variant data from the selected swatch. |
src/utils/index.ts |
Re-exports productDisplay utilities for internal consumption. |
src/types/productCardTypes.ts |
Adds swatch-related types and new ProductCard props/overrides for swatch behavior. |
src/hooks/useProductSwatch.ts |
New hook to build the swatch list and track the selected swatch. |
src/components/product-card.tsx |
Renders swatches, wires callbacks, and updates displayed product + tracking attributes. |
src/stories/components/ProductCard/ProductCard.stories.tsx |
Adds Storybook stories showcasing color/image swatches and callbacks. |
spec/hooks/useProductSwatch.test.ts |
Adds unit tests for swatch list building and selection behavior. |
spec/components/product-card/product-card.test.tsx |
Adds integration tests for swatch UI behavior and tracking attributes. |
Comments suppressed due to low confidence (1)
src/components/product-card.tsx:496
handleProductClickdispatches the click event and callsonProductClickwithproductfrom props, while the card’s tracking data attributes and the rendered content are based ondisplayProduct(selected swatch). After selecting a swatch, this makes the click event payload/callback inconsistent with what the user is seeing and with the updateddata-cnstrc-item-variation-id. Use the displayed/selected product (e.g.displayProductorrenderProps.productfrom context) for the click event detail andonProductClick.
const handleProductClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
// Do not fire if a conversion button (AddToCart / Wishlist) or swatch is clicked
if (
target.closest('[data-cnstrc-btn]') ||
target.closest('.cio-product-card-swatch-section')
) {
return;
}
dispatchCioEvent(CIO_EVENTS.productCard.click, { product }, e.currentTarget);
onProductClick?.(product);
},
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function useProductSwatch(product: Product): ProductSwatchObject { | ||
| const swatchList = useMemo(() => buildSwatchList(product), [product]); | ||
|
|
||
| const [selectedSwatch, setSelectedSwatch] = useState<SwatchItem | undefined>(() => | ||
| swatchList.find((swatch) => swatch.variationId === product.variationId), | ||
| ); |
There was a problem hiding this comment.
selectedSwatch is initialized from swatchList once, but it won’t update if the product prop changes (including product.variationId or product.swatchList). This can leave stale selection when the hook is reused for a different product or when swatches load asynchronously. Sync selectedSwatch when product.variationId/swatchList changes (e.g. via an effect that resets selection to the matching swatch, or clears it when none match).
| price?: string | number; | ||
| salePrice?: string | number; | ||
| swatchPreview: string; | ||
| variationId?: string; |
There was a problem hiding this comment.
SwatchItem.variationId is optional in the type, but the swatch UI relies on it for React keys, data-testid, data-cnstrc-item-variation-id, selection comparison, and (as a fallback) aria-label. If a consumer omits variationId, this will produce duplicate/unstable keys and can create unlabeled buttons (accessibility issue). Either make variationId required for SwatchItem (recommended) or update the UI to generate a stable fallback key/id and always provide a non-empty accessible label.
| variationId?: string; | |
| variationId: string; |
There was a problem hiding this comment.
this is a good call. there should always be a variation-id tied to a particular swatch imo. wdyt?
| @@ -0,0 +1,13 @@ | |||
| import { Product, SwatchItem } from '@/types/productCardTypes'; | |||
There was a problem hiding this comment.
getDisplayProduct only uses Product/SwatchItem as TypeScript types, but they’re imported as values. The codebase consistently uses import type for type-only imports (e.g. src/utils/events.ts:1), and using value imports here can introduce unnecessary runtime dependencies/circular-import risk when re-exported via src/utils/index.ts. Switch this to a type-only import.
| import { Product, SwatchItem } from '@/types/productCardTypes'; | |
| import type { Product, SwatchItem } from '@/types/productCardTypes'; |
4f4d960 to
11fa062
Compare
There was a problem hiding this comment.
Code Review
This PR adds a product swatch feature to the ProductCard component, including a new useProductSwatch hook, SwatchSection compound component, type definitions, utility helpers, stories, and comprehensive tests — overall a solid, well-structured implementation.
Inline comments: 8 discussions added
Overall Assessment:
| export function useProductSwatch(product: Product, maxSwatches?: number): ProductSwatchObject { | ||
| const swatchList = useMemo(() => buildSwatchList(product), [product]); | ||
|
|
||
| const [selectedSwatch, setSelectedSwatch] = useState<SwatchItem | undefined>(() => |
There was a problem hiding this comment.
Critical Issue: The useState initializer runs only once on mount and captures the initial swatchList value. If product (and therefore swatchList) changes after mount — e.g. the parent passes a new product — selectedSwatch will never be re-initialised to match the new product.variationId. This creates a stale-state bug where the selected swatch remains from the previous product.
Consider a useEffect (or deriving the selected swatch from state via a controlled approach) that resets selectedSwatch whenever product.variationId or swatchList changes:
useEffect(() => {
setSelectedSwatch(swatchList.find((s) => s.variationId === product.variationId));
}, [product.variationId, swatchList]);| return twMerge(clsx(inputs)); | ||
| } | ||
|
|
||
| export function isHexColor(value?: string): boolean { |
There was a problem hiding this comment.
Important Issue: The current implementation only handles 6-digit hex colours (#RRGGBB, length === 7). It will return false for valid 3-digit shorthands (#RGB, length === 4) and 8-digit hex-alpha values (#RRGGBBAA, length === 9) that are valid CSS. While this may be an intentional constraint, it should be documented with a comment to avoid future confusion, and the isHexColor function should reject hex strings like #GGG (non-hex digits after # are rejected by Number(0x...), but #0x1 would incorrectly pass — e.g. Number('0x0x1') is NaN so it's fine, but the use of Number() rather than a regex is fragile). A clearer approach:
export function isHexColor(value?: string): boolean {
return typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value);
}| price?: string | number; | ||
| salePrice?: string | number; | ||
| swatchPreview: string; | ||
| variationId?: string; |
There was a problem hiding this comment.
Important Issue: SwatchItem.variationId is typed as optional (variationId?: string). However, the entire swatch feature depends on variationId for key, data-testid, data-cnstrc-item-variation-id, aria-pressed selection comparison, and selectSwatch identity. Without it, swatches render with key={undefined} (React warning), data-testid='cio-swatch-undefined', and selection state breaks. This field should be required (variationId: string) — or the component must gracefully handle its absence and tests should cover that edge case.
| [renderProps, selectSwatch], | ||
| ); | ||
|
|
||
| const defaultSetUrl = useCallback((url: string) => { |
There was a problem hiding this comment.
Suggestion: defaultSetUrl uses window.location.assign, which will throw in SSR / Node environments. The rest of the codebase already guards against SSR (see getPreferredColorScheme in styleHelpers.ts). Add a guard here:
const defaultSetUrl = useCallback((url: string) => {
if (typeof window !== 'undefined') {
window.location.assign(url);
}
}, []);| return labelProp ?? 'View more >'; | ||
| }, [props.showMoreSwatchesLabel, renderProps.showMoreSwatchesLabel, hiddenSwatches.length]); | ||
|
|
||
| if (!swatchList.length) return null; |
There was a problem hiding this comment.
Suggestion: The early-return guard (if (!swatchList.length) return null) is placed after the useMemo for showMoreLabel. This is fine for correctness, but the early return means the memoised label computation runs even when there are no swatches. Moving the guard before the useMemo (or before all hooks) is not possible due to React's rules of hooks. The existing placement is correct, but the showMoreLabel memo could be conditional on swatchList.length > 0 to avoid unnecessary work when the list is empty.
Mudaafi
left a comment
There was a problem hiding this comment.
First pass review: great job porting over the PLP implementation into shared components. It took a while to figure out what we're doing w/o a PR description :p but I like the choices you've made here. I left some comments around naming and structuring before I review the new docs so ping me when it's ready for a second review
| @@ -0,0 +1,13 @@ | |||
| import { Product, SwatchItem } from '@/types/productCardTypes'; | |||
|
|
|||
| export function getDisplayProduct(product: Product, selectedSwatch?: SwatchItem): Product { | |||
There was a problem hiding this comment.
| export function getDisplayProduct(product: Product, selectedSwatch?: SwatchItem): Product { | |
| export function getProductWithVariationRollup(product: Product, selectedSwatch?: SwatchItem): Product { |
| description?: ComponentOverrideProps<ProductCardProps>; | ||
| rating?: ComponentOverrideProps<ProductCardProps>; | ||
| price?: ComponentOverrideProps<ProductCardProps>; | ||
| swatch?: ComponentOverrideProps<ProductCardProps>; |
There was a problem hiding this comment.
| swatch?: ComponentOverrideProps<ProductCardProps>; | |
| swatches or productSwatch?: ComponentOverrideProps<ProductCardProps>; |
| ...getProductCardDataAttributes(displayProduct), | ||
| }, | ||
| componentOverrides, | ||
| swatch, |
There was a problem hiding this comment.
shouldn't this be inside the renderProps object? It'll be hard for a custom swatch component to trigger a re-render without access to the swatch.selectSwatch function
| function buildSwatchList(product: Product): SwatchItem[] { | ||
| if (!product.swatchList?.length) return []; | ||
|
|
||
| return product.swatchList.reduce<SwatchItem[]>((list, item) => { | ||
| if (item.swatchPreview) { | ||
| list.push({ | ||
| name: item.name || product.name, | ||
| url: item.url, | ||
| imageUrl: item.imageUrl || product.imageUrl, | ||
| price: item.price ?? product.price, | ||
| salePrice: item.salePrice ?? product.salePrice, | ||
| swatchPreview: item.swatchPreview, | ||
| variationId: item.variationId, | ||
| rolloverImage: item.rolloverImage, | ||
| }); | ||
| } | ||
| return list; | ||
| }, []); | ||
| } |
There was a problem hiding this comment.
Correct me if I'm wrong but I don't think we need this since the parent component re-creates the rendered fields using utils.getDisplayProduct right? In that case, the swatchList can just be product.swatchList
| } | ||
|
|
||
| function ProductCard({ componentOverrides, children, className, ...props }: ProductCardProps) { | ||
| const swatch = useProductSwatch(props.product, props.maxSwatches); |
There was a problem hiding this comment.
wdyt of expanding the individual fields and including it as part of renderProps? I'm cool either way, but it feels a little confusing that swatch contains methods too
| price?: string | number; | ||
| salePrice?: string | number; | ||
| swatchPreview: string; | ||
| variationId?: string; |
There was a problem hiding this comment.
this is a good call. there should always be a variation-id tied to a particular swatch imo. wdyt?
| onSwatchClick?: (e: React.MouseEvent, swatch: SwatchItem) => void; | ||
| onShowMoreSwatches?: ( | ||
| event: React.MouseEvent, | ||
| selectedSwatch: SwatchItem | undefined, | ||
| hiddenSwatches: SwatchItem[], | ||
| setUrl: (url: string) => void, | ||
| ) => void; | ||
| maxSwatches?: number; | ||
| showMoreSwatchesLabel?: string | ((hiddenCount: number) => string); |
There was a problem hiding this comment.
This is fine since SwatchSection is a sub-component of ProductCard and so they are tightly coupled to each other. Just calling this out because it's against the pattern of Compound Components which would prefer you override the nested components directly if you want to pass props.
| @@ -0,0 +1,13 @@ | |||
| import { Product, SwatchItem } from '@/types/productCardTypes'; | |||
| }); | ||
|
|
||
| describe('Swatch Section', () => { | ||
| const mockProductWithSwatches = { |
There was a problem hiding this comment.
Can you update the main mockProductData with swatch information instead? It should be part of the default mock imo to ensure it doesn't break other things
| expect(card.getAttribute('data-cnstrc-item-variation-id')).toBe('var-2'); | ||
| }); | ||
|
|
||
| test('component override for swatch section works', () => { |
There was a problem hiding this comment.
great test. Can we also include a separate test that ensures that most/all of the current swatch implementation can be created by whatever renderProps are passed to the overridden component?
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?