Skip to content

Add composable catalog and product card parts - #1506

Merged
wcole1-godaddy merged 7 commits into
godaddy:mainfrom
sbolinger-godaddy:feat/composable-storefront-parts
Oct 16, 2026
Merged

wcole1-godaddy merged 7 commits into
godaddy:mainfrom
sbolinger-godaddy:feat/composable-storefront-parts

Conversation

@sbolinger-godaddy

@sbolinger-godaddy sbolinger-godaddy commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Why

This is PR 2 of the compound components work started in #1502. It does for the catalog and product card what #1502 did for ProductDetails. Layouts can be customized without forking or adding props, and the packaged components stay the default.

Also fixed a couple minor visual issues with spacing.

AAB and other agents should keep using the packaged components, <Catalog /> and <ProductCard />. The parts are an opt-in option for when a layout genuinely needs more customization.

The parts can't produce impossible states. Card parts take no product, SKU or price props. Inside CatalogProducts, ProductCardRoot takes no product prop either and throws if given one, so a card always shows the product it is rendered for. A direct add is still offered only for a product with one SKU and no options, and sample products only ever reach the sample cart.

What

  • Catalog parts: CatalogRoot (loads one cursor page, or the built-in samples when sampleProducts is set and the store is unbound), CatalogHeader, CatalogSummary, CatalogProducts and CatalogPagination.
  • Card parts: ProductCardRoot, ProductCardImage, ProductCardTitle, ProductCardDescription, ProductCardPrice, ProductCardAction, plus a read-only useProductCard() hook. ProductCardImage is the card's accessible product link when there's no ProductCardTitle; with a title, the title is the only link keyboard and screen reader users reach.
  • How a custom card works: whatever you put inside CatalogProducts is the card, rendered once per product. Outside a catalog, ProductCardRoot needs a product prop, for a featured product the host already has.
  • Catalog and ProductCard are now composed from these parts. The README has a "Custom catalog layouts" section with the packaged layout written out.

Not repeating the #1502 fixes

  • Host classes: every part puts className on a commerce-part wrapper the CSS reset skips. Catalog cards are now direct children of the grid, so host placement classes like col-span-2 work. The shipped-CSS conflict check from Add composable product details parts #1502 moved to test-css.ts and covers every catalog and card part, live and sample.
  • IDs: catalog and card parts create no element IDs, so rendering one twice can't duplicate an ID.
  • Naming: every part uses its root's prefix (Catalog* or ProductCard*).
  • README drift: the test app renders the README's written-out layout and checks it is pixel-identical to <Catalog sampleProducts />, which it writes out.

Visual changes

There's no backwards compatibility for these.

  • Even card heights: catalog cards in the same row are now the same height, with their price rows aligned at the bottom. In 0.2.0 each card was its own height.
  • Sample summary padding: the sample catalog's summary row uses the live catalog's padding, 32px instead of 24px above and below from sm.
  • Purchase area spacing: the ProductDetails purchase area used to cancel its own gaps to match 0.2.0, so the options, messages and quantity touched. Options are now 24px apart, the status or stock message sits 8px under the options, and the quantity and button are 24px apart.
  • Select chevron: product option selects draw their own chevron 12px in from the right edge instead of the browser's arrow, and their text starts at the same 12px padding as other inputs.
  • Markup: each part has a wrapper <div>, catalog cards are direct grid children instead of each having its own surface wrapper, and the sample summary text is a <p> inside a <div>.

Notes

  • Loading placeholder: it always shows 1–3 columns, whatever grid the host uses. It renders just before the host's grid, along with the error and empty messages. The README notes this.
  • Standalone cards: a standalone <ProductCard /> keeps its natural height. Even heights apply inside the catalog grid.
  • Next: the cart drawer is PR 3.

Testing

Package checks: typecheck, lint, build and all 59 storefront tests pass, 7 of them new. They cover:

  • a custom catalog and card layout, including the purchase and pagination
  • samples in a custom layout, which never reach the network
  • the error and retry before a custom grid
  • a card nested inside a catalog card
  • one accessible product link per card, with and without a title
  • errors that name a misplaced part
  • the shipped-CSS check for host classes on every part

Test app (commerce-component-test-app, installs the pnpm pack tarball the way a host would):

  • Smoke run: all 15 checks pass on both this PR and main. This PR adds checks for cursor pagination, a phone-width catalog, the sample catalog and cart, switching between a connected store and no store without a reload, and a standalone ProductCard.
  • Parts page: all 9 checks pass. They cover layered Tailwind-v4-style host classes on every catalog and card part, live and sample; a 4-column host grid with cards spanning 2 columns; a standalone card; even card heights and aligned price rows at desktop, tablet and sample; and the README layout matching <Catalog /> pixel-for-pixel on 4 screens: live, paged, sample and phone width. I ran the card-height check against the old build first, and it failed with a 24px difference.
  • Pixel comparison against main: product pages, the not-found page, the cart drawer and the phone-width catalog are unchanged apart from the purchase-area spacing and the select chevron. A standalone ProductCard is pixel-identical to main. I checked that each other difference comes from an intended change:
    • Catalog: changed pixels are limited to the stretched cards.
    • Sample catalog: adding 8px above and below the summary text to main's screenshot reproduces this branch's screenshot exactly.
    • Product pages: changes are limited to the purchase column below the description.
Screenshot 2026-10-15 at 1 19 40 PM Screenshot 2026-10-15 at 1 27 04 PM

sbolinger-godaddy and others added 3 commits October 14, 2026 14:19
Catalog is now composed from CatalogRoot with CatalogHeader, CatalogSummary,
CatalogProducts and CatalogPagination, and ProductCard from ProductCardRoot
with image, title, description, price and action parts. CatalogProducts
renders its children as the card for every product, so card parts take no
product, SKU or price props and samples share the same card.

Applies the lessons from the product details review: host classes go on a
commerce-part wrapper the CSS reset skips, catalog cards are direct grid
children so placement classes work, every part uses its root's prefix, and
the README layout is checked pixel-for-pixel against the packaged component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cards stretch to their grid row and line up their price rows. Drops self-start, which kept the uneven heights from 0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The purchase area cancelled its own gaps to match 0.2.0, so options, messages and quantity touched. Use a flex gap, with the status and stock messages 8px under the options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a3fcecc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@godaddy/gd-commerce-storefront Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

sbolinger-godaddy and others added 3 commits October 15, 2026 13:15
ProductCardImage always hid its link from the keyboard and screen readers, assuming ProductCardTitle supplied one. A card without a title had no accessible product link. The title now tells the root it's rendered, and the image link is hidden only then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native arrow sat against the border and ignored padding. Draw a chevron 12px in from the edge. Also note in the README that host classes sharing a name with a package utility lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Say which Catalog the README layout writes out, note the smaller checking message in the spacing changeset, and export ProductCardProps alongside the catalog prop types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy
sbolinger-godaddy marked this pull request as ready for review October 15, 2026 21:00
@sbolinger-godaddy
sbolinger-godaddy requested a review from a team as a code owner October 15, 2026 21:00

@wcole1-godaddy wcole1-godaddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving since this is experimental, but please follow up on the first two inline comments soon. Both are regressions from the current behavior: host layout classes get overridden, and the image link is focusable before hydration. The other inline comments are small fixes worth folding in if you're touching the code anyway.

Non-blocking cleanup:

  • product-purchase.tsx:26: the local const message shadows the message() formatter imported from ./api in the same file. Something like belowOptions would avoid confusion.
  • product-card.tsx:110: ProductCardRoot adds a second, state-based way to count mounted parts, alongside the ref-Map register in product-details-context.tsx. It also copies the label ?? name ?? 'Product' fallback. A shared hook and a getProductName() helper would keep them in sync.
  • ProductCardInternal could be a discriminated union ({ source: 'live'; href: string } | { source: 'sample'; href: null }). That would remove the href ?? '' and item.source === 'live' && href guards.
  • The CatalogContext and card context values are rebuilt on every render, so every card re-renders on any refetch. useMemo would avoid that.

if (attributes.length === 0 && skuOptions.length === 0) return null;
return (
<div className={partClass('space-y-6', className)}>
<div className={partClass('flex flex-col gap-6', className)}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching from space-y-6 to flex flex-col gap-6 on the element that also gets the host className means the package's unlayered .flex and .gap-6 beat a Tailwind v4 host's layered utilities. For example, <ProductDetailsOptions className="grid grid-cols-2 gap-4" /> still renders as a single column with 24px gaps. The same applies to ProductDetailsPurchase (line 28) and CatalogPagination (flex justify-between, catalog.tsx:209). That contradicts the README's statement that className controls layout. The conflict test only checks margin, padding, border, color and font, so it doesn't catch this. Could the default layout go on an inner wrapper, or be skipped when the host passes a className?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3fcecc. ProductDetailsOptions and ProductDetailsPurchase are back to space-y-6, which styles the children, so host display, gap and grid classes on the part apply. CatalogPagination's flex layout moved to an inner element. The conflict test now also checks display, gap, flex, grid columns and width, and that caught the same problem in ProductDetailsPrice (from #1502), which got the inner-element fix too. In a host grid, each control still keeps its 24px bottom margin, as on main; the README now says so. The test app has a new check for your example (grid grid-cols-2 on ProductDetailsOptions): it fails on the previous build and passes now. Default screenshots are pixel-identical.

{item.source === 'live' && href ? (
<Link
to={href}
tabIndex={hasTitleLink ? -1 : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasTitleLink is only set by the useEffect on line 178, so the server HTML and the first client render expose this image link as a focusable, non-hidden duplicate of the title link. That's two tab stops and two same-named links per card until hydration, or permanently if JS fails. Every card also re-renders once after mount. The previous card hard-coded tabIndex={-1} and aria-hidden. Could this be decided during render, or hidden by default?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3fcecc. The image link now starts hidden (tabIndex={-1}, aria-hidden), as in the previous card, so server HTML and the first render have one link per card. Titles register through a ref-based registry, and the root's effect runs after its children's, so only a card without a title link re-renders to expose the image link. The packaged card doesn't re-render. A new renderToString test covers this.

*/
export function CatalogProducts({ className, children }: CatalogProductsProps): ReactElement {
const catalog = useCatalogInternal('CatalogProducts');
const card: ReactNode = children ?? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

children ?? only falls back for null and undefined. A conditional template like {useCustom && <ProductCardRoot>…</ProductCardRoot>} with useCustom=false renders an empty slot per product, while CatalogSummary still says "Showing N products". Consider checking children == null || children === false || children === '', or using Children.count.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3fcecc. CatalogProducts now uses Children.toArray and ignores empty strings, so a false, null or empty template renders the packaged card. Added a test.

</section>
</CatalogContext.Provider>
) : (
<CommerceStatus />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host className is dropped in this branch, so "Connecting to the store…" renders without the host's padding and width, and the layout jumps when the real <section> replaces it. Could this be wrapped in an element that applies the host classes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3fcecc. The connection status is wrapped in an element with the host classes. ProductDetailsRoot had the same problem, including its loading, error and not-found states, so those keep the host className now too. Added a test.

if (status === 'ready') return null;
return (
<div className={partClass(className)}>
{status === 'variant-error' ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested ternary loses the exhaustiveness check the switch gave us: a new ProductDetailsStatus value would fall into the last branch silently. It also breaks the repo's "no nested ternaries" rule. Could we keep the switch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3fcecc. Restored the switch without a default case, so a new status leaves content unassigned and TypeScript reports it. I also removed the other nested ternaries this PR added (CatalogRoot, CatalogSummary).

…ge link

- Price, Options, Purchase and Pagination keep their default layout off the
  element that gets the host className, so host display, gap and grid apply.
  Purchase is back to space-y-6; the -mt-4 messages collapse to the same 8px.
- Roots keep the host className while connecting, loading or erroring.
- The card's image link is hidden by default and exposed after mount only when
  no title link registered, so server HTML has one link per card.
- A false or empty CatalogProducts template falls back to the packaged card.
- Restore the purchase status switch; remove nested ternaries in new code.
- Share the part registry between ProductDetailsRoot and ProductCardRoot, add
  getProductName, make the card context a discriminated union, and memoize
  catalog and card context values.
- The conflict test now also checks display, gap, flex, grid columns and width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbolinger-godaddy

Copy link
Copy Markdown
Contributor Author

Thanks for the review. a3fcecc addresses all five inline comments (replies are on each). It also covers the non-blocking cleanup:

  • Renamed the local message to belowOptions.
  • ProductDetailsRoot and ProductCardRoot share one usePartRegistry hook, and a new getProductName() helper holds the name fallback.
  • The card context is a discriminated union on source, which removes the href ?? '' and item.source === 'live' && href guards.
  • The catalog and card context values, the catalog items and the default card element are memoized, so a refetch with unchanged data doesn't re-render every card.

Package tests: 62 pass. Test app: smoke 15/15, parts 10/10, and all 34 screenshots are pixel-identical to the previous build.

🤖 Generated with Claude Code

@wcole1-godaddy
wcole1-godaddy merged commit a3824b8 into godaddy:main Oct 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants