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
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
| Prop | Type | Description |
|---|---|---|
listing | Omit<ProductListing, "items"> & { items: { variant: ListingProduct | null; }[]; } | |
search | string | 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 |
pathname? | string | |
renderPageLink? | ProductListingPageLinkRenderer | |
defaultSort? | ProductSortOrder | The order without a |
layouts? | readonly ListingLayout[] | The layouts the toggle offers: |
defaultLayout? | ListingLayout | |
onLayoutChange? | ((layout: ListingLayout) => void) | |
table? | Pick<ProductListingTableProps<ListingProduct>, "renderLink" | "href" | "renderAction" | "vatDisplay"> | The order table's links and add button; needed when |
filtersSheet? | Pick<ProductListingFiltersSheetProps, "mode" | "config"> | The narrow screen's sheet: |
promos? | readonly { id: string; }[] | A catalog page's |
renderPromo? | ProductListingPromoRenderer<{ id: string; }> | Renders one promo; needed with |
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.
| Prop | Type | Description |
|---|---|---|
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 |
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.
| Prop | Type | Description |
|---|---|---|
className? | string | The trigger's class, to hide it where the sidebar shows: |
mode? | "live" | "apply" |
|
config? | ProductListingConfig | null | The category's scope, so the |
ProductListingGridThe products of one page, one li each.
| Prop | Type | Description |
|---|---|---|
items | { variant: ListingProduct | null; }[] | A listing's |
renderItem | ProductListingItemRenderer<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.
| Prop | Type | Description |
|---|---|---|
layouts? | readonly ListingLayout[] | The layouts on offer, the first being the fallback: |
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 |
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.
| Prop | Type | Description |
|---|---|---|
grid | ReactNode | |
table | ReactNode |
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.
| Prop | Type | Description |
|---|---|---|
pagination | ListingPagination | |
search | string | 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.
| Prop | Type | Description |
|---|---|---|
pagination | ListingPagination |
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 …").
| Prop | Type | Description |
|---|---|---|
searchTerm | string | What the shopper searched for. |
correctedSearchTerm | string | The term the engine found the products for instead. |
search | string | 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.
| Prop | Type | Description |
|---|---|---|
defaultSort? | ProductSortOrder | The order the listing has without a |
options? | ProductSortOrder[] | The orders to offer, in this order: the listing's |
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.
| Prop | Type | Description |
|---|---|---|
categories | readonly 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" |
|
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.
| Prop | Type | Description |
|---|---|---|
items | { variant: ListingProduct | null; }[] | A listing's |
href | (item: ListingProduct) => string | The product page of a row — the app's link builder. |
renderAction | ProductListingTableActionRenderer<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.
| Empty | ProductListingEmpty | A listing with no products: a title, a hint to widen the search, and the site's actions. |
| Loading | ProductListingSkeleton | Stands 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} />;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} />;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> );}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} />;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}/>;Filters Sheet ApplyOn a phone, the filters wait in a sheet.
<ProductListing listing={listing} filtersSheet={{ mode: "apply", config: page.productListingConfig }} renderItem={renderCard}/>;
