Skip to content

ShoppingListDetail

Pure presentation for a shopping list — the stable extension seam (§6). It never fetches; the caller passes the list it owns (default query or an extended one) and seeds it into context. Compose the pieces (ShoppingListDetailHeader / ShoppingListDetailTitle / ShoppingListDetailActions / ShoppingListDetailItems / ShoppingListDetailTotal / ShoppingListDetailEmpty) as children for a bespoke layout, or omit children (plus a renderItem) for the standard one. Render it from a site page (or a Storybook demo) that owns the query.

import { ShoppingListDetail } from "@evolve-storefront/feature-shopping-list/shopping-list-detail";

Renders inside ShoppingListProvider.

Preview

DefaultOpen in Storybook ↗

Usage

Default composition — omit children and pass renderItem; the view renders its standard header / count / items layout, ending with the total at today's prices.

const { data: list } = useQuery(
shoppingListDetailQueryOptions({ gqlClient, scope, shoppingListId }),
);
if (!list) return null;
// No children + a renderItem = the standard composition.
<ShoppingListDetail list={list} renderItem={renderItem} />;

Props

No props of its own.

Change the layout

Pass children to ShoppingListDetail to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-shopping-list/shopping-list-detail.

<ShoppingListDetail list={list}>
<ShoppingListDetailHeader>
<ShoppingListDetailTitle name={list.name} />
<ListActions list={list} /> {/* e.g. a kebab menu */}
</ShoppingListDetailHeader>
<ShoppingListDetailActions itemCount={list.lineItems.length}>
<AddAllToCartButton items={list.lineItems} />
</ShoppingListDetailActions>
<ShoppingListDetailItems
items={list.lineItems}
renderItem={renderItem}
className="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-4"
/>
</ShoppingListDetail>;
ShoppingListDetailLayoutThe standard composition, rendered when ShoppingListDetail gets no children — keeps Depth-0 usage and the default Storybook story trivial.

No props of its own.

ShoppingListDetailActionsThe product-count row.
PropTypeDescription
itemCountnumber

Total items in the list, not the page slice — the count must survive paging.

children?ReactNode

Actions beside the product count — e.g. "add all to cart".

Any other props go to the root element, like on a plain HTML element.

ShoppingListDetailDescriptionThe list's optional description.

Renders nothing when there is no description, so a composed layout can place it unconditionally — the same shape ShoppingListDetailLayout had inline, which is what makes swapping one for the other invisible.

Takes the text as a prop rather than reading context, matching ShoppingListDetailTitle: the parts stay server-renderable that way.

PropTypeDescription
description?string | null

Any other props go to the root element, like on a plain HTML element.

ShoppingListDetailHeaderPure layout shell for the header (holds no data).
PropTypeDescription
children?ReactNode

Any other props go to the root element, like on a plain HTML element.

ShoppingListDetailItemsRenders the line items and owns pagination (the slice and the pager), so the count row stays the true total and there is no consumer ternary for the empty state.

renderLink pages by navigation instead, which is what makes the steps real anchors. Unlike the server-paginated list parts it does not make this piece server-renderable — it owns per-row mutations and stays a client component either way; the win here is middle-click and open-in-new-tab, not the boundary.

No props of its own.

ShoppingListDetailTitleThe list's name as the page heading.
PropTypeDescription
namestring
headingLevel?HeadingLevel

Document level. Defaults to 1 — the list detail is a whole page.

Any other props go to the root element, like on a plain HTML element.

ShoppingListDetailTotalWhat the whole list costs at today's prices, labelled excl.
PropTypeDescription
itemsreadonly ShoppingListLineItem[]

Every line of the list, not the page slice.

basis?"net" | "gross"

Tax basis: net for business buyers, who see every amount excl. VAT. Defaults to gross.

Any other props go to the root element, like on a plain HTML element.

ShoppingListItemHeartThe filled heart on a saved item: pressing it removes the item, as the product page's ShoppingListAddButton does.
PropTypeDescription
itemShoppingListLineItem
apiShoppingListDetailItemApi

The item's bound api from renderItem.

type?"reset" | "button" | "submit"
isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

asChild?boolean
data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

Any other props go to the root element, like on a plain HTML element.

ShoppingListItemSaleBadge"-20%" for an item on sale, from its price against its regularPrice; nothing otherwise.
PropTypeDescription
itemShoppingListLineItem

Any other props go to the root element, like on a plain HTML element.

Loading and empty states

Render these while the data loads or when there is nothing to show, so the page does not jump when it arrives.

EmptyShoppingListDetailEmptyThe empty state, rendered by ShoppingListDetailItems when there are no items.
LoadingShoppingListDetailSkeletonLoading state for ShoppingListDetail.

Examples

EmptyAn empty list renders ShoppingListDetailEmpty itself — no consumer ternary.
<ShoppingListDetail list={list} renderItem={renderItem} />;
Open in Storybook ↗
Custom EmptyCustom empty state: pass a node to ShoppingListDetailItems' empty prop.
<ShoppingListDetail list={list}>
<ShoppingListDetailHeader>
<ShoppingListDetailTitle name={list.name} />
</ShoppingListDetailHeader>
<ShoppingListDetailItems
items={list.lineItems}
renderItem={renderItem}
empty={
<ShoppingListDetailEmpty className="flex flex-col items-center gap-2 py-16 text-center">
<p className="font-medium text-foreground text-lg">Your list is empty</p>
<p>Browse the catalog and tap the heart to save products here.</p>
</ShoppingListDetailEmpty>
}
/>
</ShoppingListDetail>;
Open in Storybook ↗
PaginatedBuilt-in pagination: with more items than pageSize, ShoppingListDetailItems renders the pager itself (feature-owned page state here — pass page/onPageChange to sync it with a URL query instead).
// pageSize alone → self-managed page state.
// Add page + onPageChange to drive it from a URL query.
<ShoppingListDetail list={list} renderItem={renderItem} pageSize={4} />;
Open in Storybook ↗
GridThe single-shopper wishlist as a grid of cards: two across on a phone, more as it widens, never scrolling sideways.
<ShoppingListDetail
list={list}
layout="grid"
renderItem={(item, api) => (
<ProductCard
product={toCardProduct(item)}
href={productHref(item.slug)}
ribbon={<ShoppingListItemSaleBadge item={item} />}
actions={<ShoppingListItemHeart item={item} api={api} />}
/>
)}
/>;
Open in Storybook ↗