[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); | |
| }} |
| ); | ||
| const [isExpanded, setIsExpanded] = useState(false); | ||
|
|
||
| const productKey = JSON.stringify([id, variationId ?? null]); |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
There was a problem hiding this comment.
The suggestion is valid but minor, JSON.stringify on a tiny array works fine and is correct
| props.showViewMoreSwatches ? props.maxSwatches : undefined, | ||
| ); | ||
| const displayProduct = useMemo(() => { | ||
| const filtered = Object.fromEntries( |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
There was a problem hiding this comment.
The Object.fromEntries pattern is slightly verbose but functionally correct and safe. The "bug" described (a swatch intentionally having undefined to clear a field) contradicts how the TypeScript types are designed. SwatchItem fields are optional (absent when not applicable), not nullable
| componentOverrides, | ||
| }), | ||
| [props, componentOverrides], | ||
| [props, displayProduct, swatch, componentOverrides], |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
There was a problem hiding this comment.
This is correct that the object envelope is a new reference every render. However, props in the dependency array has the exact same problem. It's the component's props object, which is also a new reference every render. So the useMemo already recomputes every render due to props.
| onViewMoreSwatchesClick?.(e, selected); | ||
| }, | ||
| [product, onProductClick], | ||
| [expandInline, swatch.onViewMoreSwatchesClick, onViewMoreSwatchesClick], |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
There was a problem hiding this comment.
The callback is already stable via useCallback([], []) inside the hook - destructuring it out wouldn't change behavior, just adds noise
| }) => { | ||
| const { componentOverrides, renderProps } = useProductCardContext(); | ||
|
|
||
| if (!swatchList?.length) return null; |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
Alexey-Pavlov
left a comment
There was a problem hiding this comment.
LGTM! Thanks for working on this!
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?