Skip to content

ProductListing

One page of a product listing. Without children it renders the standard layout: the filters beside the products, a toolbar with the count, the sort and the layout toggle, the filters in force, the grid (or the order table) and the pager, or the empty state when nothing matches. Server-renderable inside a ProductListingProvider, which its filters and sort read.

import { ProductListing } from "@evolve-storefront/feature-product/product-listing";

Renders inside ProductListingProvider.

Preview

DefaultOpen in Storybook ↗

Usage

The standard layout, which offers the grid and the list. The toggle turns the grid into rows, and the card follows.

<ProductListing
listing={listing}
defaultLayout={cookies.get("listing-layout") ?? "grid"}
onLayoutChange={(layout) => saveLayoutCookie(layout)}
renderItem={(variant, { index }) => (
<ProductCard
product={variant}
href={productHref(variant.product.slug)}
loading={index === 0 ? "eager" : "lazy"}
/>
)}
/>;

Props

PropTypeDescription
listingOmit<ProductListing, "items"> & { items: { variant: ListingProduct | null; }[]; }
searchstring | URLSearchParams

The URL's parameters, so the pager's links keep the filters, the sort and the search.

renderItem?ProductListingItemRenderer<ListingProduct>

Renders each product; needed unless children composes the listing.

pathname?string
renderPageLink?ProductListingPageLinkRenderer
defaultSort?ProductSortOrder

The order without a sort in the URL: the CMS's, or RELEVANCE.

layouts?readonly ListingLayout[]

The layouts the toggle offers: ["grid"], ["grid", "list"] (the default) or ["grid", "table"].

defaultLayout?ListingLayout
onLayoutChange?((layout: ListingLayout) => void)
table?Pick<ProductListingTableProps<ListingProduct>, "renderLink" | "href" | "renderAction" | "vatDisplay">

The order table's links and add button; needed when layouts has table.

filtersSheet?Pick<ProductListingFiltersSheetProps, "mode" | "config">

The narrow screen's sheet: { mode: "apply", config } applies its filters on its button.

promos?readonly { id: string; }[]

A catalog page's promos, shown in the grid on the first page of an unfiltered listing.

renderPromo?ProductListingPromoRenderer<{ id: string; }>

Renders one promo; needed with promos.

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

Change the layout

Pass children to ProductListing to arrange its parts yourself. Every part is exported from @evolve-storefront/feature-product/product-listing.

<ProductListing listing={listing} search={searchParams}>
<div className="flex items-end justify-between">
<ProductListingResultCount pagination={listing.pagination} />
<ProductListingSort />
</div>
<ProductListingGrid items={listing.items} renderItem={renderCard} />
<ProductListingPagination pagination={listing.pagination} search={searchParams} />
</ProductListing>;
ProductListingActiveFiltersOne chip per filter in force, each removing its filter, and one that clears them all.

No props of its own.

ProductListingFiltersEvery facet of the listing: the toggles together on top, then each other facet as an open accordion section.
PropTypeDescription
allowsMultipleExpanded?boolean

Let more than one section stand open. Default: one at a time.

defaultExpandedKeys?Iterable<string>

Ids of the sections open on first render.

expandedKeys?Iterable<string>

The open sections, for a caller that owns the state.

onExpandedChange?((keys: Set<string>) => void)

Reports the open set. Pairs with expandedKeys.

headingLevel?HeadingLevel

The level of each facet's heading.

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

ProductListingFiltersSheetThe filters in a sheet, behind a button that counts the filters in force.
PropTypeDescription
className?string

The trigger's class, to hide it where the sidebar shows: lg:hidden.

mode?"live" | "apply"

live (the default) applies each change at once, like the sidebar. apply keeps changes in the sheet as a draft, its button counting what they would show, and applies them in one navigation; a selection that matches nothing cannot be applied. On a phone, apply spares a reload per tap.

config?ProductListingConfig | null

The category's scope, so the apply count matches what the listing will show.

ProductListingGridThe products of one page, one li each.
PropTypeDescription
items{ variant: ListingProduct | null; }[]

A listing's items, as productSearch returns them. An empty entry is skipped.

renderItemProductListingItemRenderer<ListingProduct>
promos?readonly { id: string; }[]

Promo cards to place among the products, at positions 4 and 12.

renderPromo?ProductListingPromoRenderer<{ id: string; }>

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

ProductListingLayoutHolds the layout choice and sets it as data-listing-layout.
PropTypeDescription
layouts?readonly ListingLayout[]

The layouts on offer, the first being the fallback: ["grid"] shows no toggle.

layout?ListingLayout

Controlled. Leave it out and the shopper's choice lives in this component.

defaultLayout?ListingLayout
onLayoutChange?((layout: ListingLayout) => void)

Every choice, so a site can keep it, in a cookie for instance, and pass it back as defaultLayout.

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

ProductListingLayoutSwitchShows table while the enclosing layout is table, and grid otherwise.
PropTypeDescription
gridReactNode
tableReactNode
ProductListingLayoutToggleA pressed-state button per layout the enclosing ProductListingLayout offers.

No props of its own.

ProductListingPaginationPrevious and next as links to the listing's pages.
PropTypeDescription
paginationListingPagination
searchstring | URLSearchParams

The URL's parameters, so a page keeps the filters, the sort and the search.

pathname?string
renderLink?ProductListingPageLinkRenderer

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

ProductListingResultCountHow many products the whole result holds.
PropTypeDescription
paginationListingPagination

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

ProductListingSearchCorrectionSays the engine corrected the search term because it found nothing, and links to searching the shopper's own term as typed ("Search instead for …").
PropTypeDescription
searchTermstring

What the shopper searched for.

correctedSearchTermstring

The term the engine found the products for instead.

searchstring | URLSearchParams

The URL's parameters, so searching the term as typed keeps the filters and sort.

pathname?string
renderLink?ProductListingLinkRenderer

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

ProductListingSortThe listing's order.
PropTypeDescription
defaultSort?ProductSortOrder

The order the listing has without a sort in the URL: the CMS's, or RELEVANCE.

options?ProductSortOrder[]

The orders to offer, in this order: the listing's sortOrders. One this release has no label for is left out.

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

ProductListingSubcategoriesLinks to a category's subcategories: a list beside the products on a wide screen, headed by parent, and a row of chips that scrolls sideways on a phone.
PropTypeDescription
categoriesreadonly ProductListingSubcategory[]

The current category's children, in the order to show them.

parent?Pick<ProductListingSubcategory, "path" | "name"> | null

The category they belong to, heading the list on a wide screen.

currentPath?string

The page's path; the link to it is marked as the current page.

renderLink?ProductListingSubcategoryLinkRenderer
variant?"sidebar" | "chips"

sidebar (the default) is a list beside the products from lg; chips is the row of chips at every width, above the grid, as a catalog page's LISTING layout.

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

ProductListingTableThe products of one page as a dense order table: SKU, name, stock, the price per unit as a business card shows it, a quantity stepper and the add button the site renders.
PropTypeDescription
items{ variant: ListingProduct | null; }[]

A listing's items, as productSearch returns them. An entry without a sku is skipped.

href(item: ListingProduct) => string

The product page of a row — the app's link builder.

renderActionProductListingTableActionRenderer<ListingProduct>
renderLink?((props: ProductCardLinkProps) => ReactNode)
vatDisplay?VatDisplay

The store's VAT display, as on a business card; excluding VAT by default.

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.

EmptyProductListingEmptyA listing with no products: a title, a hint to widen the search, and the site's actions.
LoadingProductListingSkeletonStands in for ProductListingGrid while a page loads, with the same ul/li and card shape.

Examples

EmptyA category with no products and no filters applied: the empty state, with no grid and no pager.
<ProductListing listing={listing} search={searchParams} renderItem={renderCard} />;
Open in Storybook ↗
LoadingThe skeleton that stands in for the grid while a page loads, above the loaded listing.
const { data: listing, isPending } = useProductSearch(input);
if (isPending) return <ProductListingSkeleton count={pageSize} />;
return <ProductListing listing={listing} search={searchParams} renderItem={renderCard} />;
Open in Storybook ↗
FilteringThe filters beside the products.
"use client";
export function ListingNavigation({ facets, children }) {
const router = useRouter();
return (
<ProductListingProvider
facets={facets}
search={useSearchParams()}
navigate={(params) => router.push(`?${params}`, { scroll: false })}
rangeUnit={(facet) => facet.key === "price" ? currencyRangeUnit("EUR") : undefined}
>
{children}
</ProductListingProvider>
);
}
Open in Storybook ↗
Out Of Range MinimumA link carrying a minimum above the price facet's max: the slider stays inside the facet, and the chip reads the minimum as open-ended instead of "€10,000 – €200".
<ProductListing listing={listing} search={searchParams} renderItem={renderCard} />;
Open in Storybook ↗
PagingA middle page of a result the engine cannot count: the pager links keep the filters.
<ProductListing
listing={listing}
search={searchParams}
renderPageLink={({ href, children }) => <Link href={href}>{children}</Link>}
renderItem={renderCard}
/>;
Open in Storybook ↗
Filters Sheet ApplyOn a phone, the filters wait in a sheet.
<ProductListing
listing={listing}
filtersSheet={{ mode: "apply", config: page.productListingConfig }}
renderItem={renderCard}
/>;
Open in Storybook ↗