Product List Section
Installation
Add the following Soul components
The product-list-section component uses the streamable, button, cursor-pagination, product-card, side-panel, skeleton, breadcrumbs and product-list components. Make sure you have added them to your project.
Install the following dependencies
npm install lucide-react
Copy and paste the following code into your project
sections/product-list-section/index.tsx
import { Sliders } from 'lucide-react';import { Stream, Streamable } from '@/vibes/soul/lib/streamable';import { Button } from '@/vibes/soul/primitives/button';import { CursorPagination, CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination';import { type Product } from '@/vibes/soul/primitives/product-card';import * as SidePanel from '@/vibes/soul/primitives/side-panel';import * as Skeleton from '@/vibes/soul/primitives/skeleton';import { type Breadcrumb, Breadcrumbs, BreadcrumbsSkeleton,} from '@/vibes/soul/sections/breadcrumbs';import { ProductList } from '@/vibes/soul/sections/product-list';import { Filter, FilterPanel } from '@/vibes/soul/sections/product-list-section/filter-panel';import { Sorting, SortingSkeleton, Option as SortOption,} from '@/vibes/soul/sections/product-list-section/sorting';export interface ProductListSectionProps { breadcrumbs?: Streamable<Breadcrumb[]>; title?: Streamable<string>; totalCount: Streamable<number>; products: Streamable<Product[]>; filters: Streamable<Filter[]>; sortOptions: Streamable<SortOption[]>; compareProducts?: Streamable<Product[]>; paginationInfo?: Streamable<CursorPaginationInfo>; compareHref?: string; compareLabel?: Streamable<string>; showCompare?: Streamable<boolean>; filterLabel?: string; filtersPanelTitle?: Streamable<string>; resetFiltersLabel?: Streamable<string>; rangeFilterApplyLabel?: Streamable<string>; sortLabel?: Streamable<string>; sortPlaceholder?: Streamable<string>; sortParamName?: string; sortDefaultValue?: string; compareParamName?: string; emptyStateSubtitle?: Streamable<string>; emptyStateTitle?: Streamable<string>; placeholderCount?: number; removeLabel?: Streamable<string>;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --product-list-section-title: hsl(var(--foreground)); * --product-list-section-title-font-family: var(--font-heading); * --product-list-section-total-count: hsl(var(--contrast-300)); * --product-list-section-filter-label-font-family: var(--font-mono); * --product-list-section-filter-label: hsl(var(--contrast-400)); * --product-list-section-filter-link: hsl(var(--contrast-500)); * --product-list-section-filter-link-hover: hsl(var(--foreground)); * --product-list-section-filter-link-font-family: var(--font-body); * } * ``` */export function ProductListSection({ breadcrumbs: streamableBreadcrumbs, title: streamableTitle = 'Products', totalCount: streamableTotalCount, products, compareProducts, sortOptions: streamableSortOptions, sortDefaultValue, filters, compareHref, compareLabel, showCompare, paginationInfo, filterLabel = 'Filters', filtersPanelTitle: streamableFiltersPanelTitle = 'Filters', resetFiltersLabel, rangeFilterApplyLabel, sortLabel: streamableSortLabel, sortPlaceholder: streamableSortPlaceholder, sortParamName, compareParamName, emptyStateSubtitle, emptyStateTitle, placeholderCount = 8, removeLabel,}: ProductListSectionProps) { return ( <div className="group/product-list-section @container"> <div className="mx-auto max-w-screen-2xl px-4 py-10 @xl:px-6 @xl:py-14 @4xl:px-8 @4xl:py-12"> <div> <Stream fallback={<BreadcrumbsSkeleton />} value={streamableBreadcrumbs}> {(breadcrumbs) => breadcrumbs && breadcrumbs.length > 1 && <Breadcrumbs breadcrumbs={breadcrumbs} /> } </Stream> <div className="flex flex-wrap items-center justify-between gap-4 pb-8 pt-6"> <h1 className="flex items-center gap-2 font-[family-name:var(--product-list-section-title-font-family,var(--font-family-heading))] text-3xl font-medium leading-none text-[var(--product-list-section-title,hsl(var(--foreground)))] @lg:text-4xl @2xl:text-5xl"> <Stream fallback={<ProductListSectionTitleSkeleton />} value={streamableTitle}> {(title) => <span>{title}</span>} </Stream> <Stream fallback={<ProductListSectionTotalCountSkeleton />} value={streamableTotalCount} > {(totalCount) => ( <span className="text-[var(--product-list-section-total-count,hsl(var(--contrast-300)))]"> {totalCount} </span> )} </Stream> </h1> <div className="flex items-center gap-2"> <Stream fallback={<SortingSkeleton />} value={Streamable.all([ streamableSortLabel, streamableSortOptions, streamableSortPlaceholder, ])} > {([label, options, placeholder]) => ( <Sorting defaultValue={sortDefaultValue} label={label} options={options} paramName={sortParamName} placeholder={placeholder} /> )} </Stream> <div className="block @3xl:hidden"> <SidePanel.Root> <SidePanel.Trigger asChild> <Button size="medium" variant="secondary"> {filterLabel} <span className="hidden @xl:block"> <Sliders size={20} /> </span> </Button> </SidePanel.Trigger> <Stream value={streamableFiltersPanelTitle}> {(filtersPanelTitle) => ( <SidePanel.Content title={filtersPanelTitle}> <FilterPanel filters={filters} paginationInfo={paginationInfo} rangeFilterApplyLabel={rangeFilterApplyLabel} resetFiltersLabel={resetFiltersLabel} /> </SidePanel.Content> )} </Stream> </SidePanel.Root> </div> </div> </div> </div> <div className="flex items-stretch gap-8 @4xl:gap-10"> <aside className="hidden w-52 @3xl:block @4xl:w-60"> <Stream value={streamableFiltersPanelTitle}> {(filtersPanelTitle) => <h2 className="sr-only">{filtersPanelTitle}</h2>} </Stream> <FilterPanel className="sticky top-4" filters={filters} paginationInfo={paginationInfo} rangeFilterApplyLabel={rangeFilterApplyLabel} resetFiltersLabel={resetFiltersLabel} /> </aside> <div className="flex-1 group-has-[[data-pending]]/product-list-section:animate-pulse"> <ProductList compareHref={compareHref} compareLabel={compareLabel} compareParamName={compareParamName} compareProducts={compareProducts} emptyStateSubtitle={emptyStateSubtitle} emptyStateTitle={emptyStateTitle} placeholderCount={placeholderCount} products={products} removeLabel={removeLabel} showCompare={showCompare} /> {paginationInfo && <CursorPagination info={paginationInfo} />} </div> </div> </div> </div> );}export function ProductListSectionTitleSkeleton() { return ( <Skeleton.Root className="group-has-[[data-pending]]/product-list-section:animate-pulse" pending > <Skeleton.Text characterCount={6} className="rounded-lg" data-pending /> </Skeleton.Root> );}export function ProductListSectionTotalCountSkeleton() { return ( <Skeleton.Root className="group-has-[[data-pending]]/product-list-section:animate-pulse" pending > <Skeleton.Text characterCount={2} className="rounded-lg" data-pending /> </Skeleton.Root> );}
sections/product-list-section/filter-panel.tsx
/* eslint-disable @typescript-eslint/no-unsafe-member-access *//* eslint-disable @typescript-eslint/no-unsafe-call *//* eslint-disable @typescript-eslint/no-unsafe-argument *//* eslint-disable @typescript-eslint/no-unsafe-assignment */'use client';import { clsx } from 'clsx';import Link from 'next/link';import { parseAsString, useQueryStates } from 'nuqs';import { ReactNode, useOptimistic, useState, useTransition } from 'react';import { Checkbox } from '@/vibes/soul/form/checkbox';import { RangeInput } from '@/vibes/soul/form/range-input';import { ToggleGroup } from '@/vibes/soul/form/toggle-group';import { Stream, Streamable, useStreamable } from '@/vibes/soul/lib/streamable';import { Accordion, AccordionItem } from '@/vibes/soul/primitives/accordion';import { Button } from '@/vibes/soul/primitives/button';import { CursorPaginationInfo } from '@/vibes/soul/primitives/cursor-pagination';import { Rating } from '@/vibes/soul/primitives/rating';import * as Skeleton from '@/vibes/soul/primitives/skeleton';import { getFilterParsers } from './filter-parsers';export interface LinkGroupFilter { type: 'link-group'; label: string; links: Array<{ label: string; href: string }>;}export interface ToggleGroupFilter { type: 'toggle-group'; paramName: string; label: string; options: Array<{ label: string; value: string; disabled?: boolean }>;}export interface RatingFilter { type: 'rating'; paramName: string; label: string; disabled?: boolean;}export interface RangeFilter { type: 'range'; label: string; minParamName: string; maxParamName: string; min?: number; max?: number; minLabel?: string; maxLabel?: string; minPrepend?: ReactNode; maxPrepend?: ReactNode; minPlaceholder?: string; maxPlaceholder?: string; disabled?: boolean;}export type Filter = ToggleGroupFilter | RangeFilter | RatingFilter | LinkGroupFilter;export interface FilterPanelProps { className?: string; filters: Streamable<Filter[]>; resetFiltersLabel?: Streamable<string>; paginationInfo?: Streamable<CursorPaginationInfo>; rangeFilterApplyLabel?: Streamable<string>;}function getParamCountLabel(params: Record<string, string | null | string[]>, key: string) { const value = params[key]; if (Array.isArray(value) && value.length > 0) return `(${value.length})`; return '';}export function FilterPanel({ className, filters: streamableFilters, resetFiltersLabel: streamableResetFiltersLabel, rangeFilterApplyLabel: streamableRangeFilterApplyLabel, paginationInfo: streamablePaginationInfo,}: FilterPanelProps) { const filters = useStreamable(streamableFilters); const resetFiltersLabel = useStreamable(streamableResetFiltersLabel) ?? 'Reset filters'; const rangeFilterApplyLabel = useStreamable(streamableRangeFilterApplyLabel); const paginationInfo = useStreamable(streamablePaginationInfo); const startCursorParamName = paginationInfo?.startCursorParamName ?? 'before'; const endCursorParamName = paginationInfo?.endCursorParamName ?? 'after'; const [params, setParams] = useQueryStates( { ...getFilterParsers(filters), [startCursorParamName]: parseAsString, [endCursorParamName]: parseAsString, }, { shallow: false, history: 'push', }, ); const [isPending, startTransition] = useTransition(); const [optimisticParams, setOptimisticParams] = useOptimistic(params); const [accordionItems, setAccordionItems] = useState(() => filters .filter((filter) => filter.type !== 'link-group') .map((filter, index) => ({ key: index.toString(), value: index.toString(), filter, expanded: index < 3, })), ); if (filters.length === 0) return null; const linkGroupFilters = filters.filter( (filter): filter is LinkGroupFilter => filter.type === 'link-group', ); return ( <div className={clsx('group/filter-panel', className)} data-pending={isPending ? true : null}> <Stream fallback={<FilterPanelSkeleton />} value={Streamable.all([ streamableFilters, streamableResetFiltersLabel, streamableRangeFilterApplyLabel, ])} > {() => ( <div className="space-y-5"> {linkGroupFilters.map((linkGroup, index) => ( <div key={index.toString()}> <h3 className="py-4 font-[family-name:var(--product-list-section-filter-label-font-family,var(--font-family-mono))] text-sm uppercase text-[var(--product-list-section-filter-label,hsl(var(--contrast-400)))]"> {linkGroup.label} </h3> <ul> {linkGroup.links.map((link, linkIndex) => ( <li className="py-2" key={linkIndex.toString()}> <Link className="font-[family-name:var(--product-list-section-filter-link-font-family,var(--font-family-body))] text-base font-medium text-[var(--product-list-section-filter-link,hsl(var(--contrast-500)))] transition-colors duration-300 ease-out hover:text-[var(--product-list-section-filter-link-hover,var(--foreground))]" href={link.href} > {link.label} </Link> </li> ))} </ul> </div> ))} <Accordion onValueChange={(items) => setAccordionItems((prevItems) => prevItems.map((prevItem) => ({ ...prevItem, expanded: items.includes(prevItem.value), })), ) } type="multiple" value={accordionItems.filter((item) => item.expanded).map((item) => item.value)} > {accordionItems.map((accordionItem) => { const { key, value, filter } = accordionItem; switch (filter.type) { case 'toggle-group': return ( <AccordionItem key={key} title={`${filter.label}${getParamCountLabel(optimisticParams, filter.paramName)}`} value={value} > <ToggleGroup onValueChange={(toggleGroupValues) => { startTransition(async () => { const nextParams = { ...optimisticParams, [startCursorParamName]: null, [endCursorParamName]: null, [filter.paramName]: toggleGroupValues.length === 0 ? null : toggleGroupValues, }; setOptimisticParams(nextParams); await setParams(nextParams); }); }} options={filter.options} type="multiple" value={optimisticParams[filter.paramName] ?? []} /> </AccordionItem> ); case 'range': return ( <AccordionItem key={key} title={filter.label} value={value}> <RangeInput applyLabel={rangeFilterApplyLabel} disabled={filter.disabled} max={filter.max} maxLabel={filter.maxLabel} maxName={filter.maxParamName} maxPlaceholder={filter.maxPlaceholder} maxPrepend={filter.maxPrepend} min={filter.min} minLabel={filter.minLabel} minName={filter.minParamName} minPlaceholder={filter.minPlaceholder} minPrepend={filter.minPrepend} onChange={({ min, max }) => { startTransition(async () => { const nextParams = { ...optimisticParams, [filter.minParamName]: min, [filter.maxParamName]: max, [startCursorParamName]: null, [endCursorParamName]: null, }; setOptimisticParams(nextParams); await setParams(nextParams); }); }} value={{ min: optimisticParams[filter.minParamName] ?? null, max: optimisticParams[filter.maxParamName] ?? null, }} /> </AccordionItem> ); case 'rating': return ( <AccordionItem key={key} title={filter.label} value={value}> <div className="space-y-3"> {[5, 4, 3, 2, 1].map((rating) => ( <Checkbox checked={ optimisticParams[filter.paramName]?.includes(rating.toString()) ?? false } disabled={filter.disabled} key={rating} label={<Rating rating={rating} showRating={false} />} onCheckedChange={(checked) => startTransition(async () => { const ratings = new Set(optimisticParams[filter.paramName]); if (checked === true) ratings.add(rating.toString()); else ratings.delete(rating.toString()); const nextParams = { ...optimisticParams, [filter.paramName]: Array.from(ratings), [startCursorParamName]: null, [endCursorParamName]: null, }; setOptimisticParams(nextParams); await setParams(nextParams); }) } /> ))} </div> </AccordionItem> ); default: return null; } })} </Accordion> <Button onClick={() => { startTransition(async () => { const nextParams = { ...Object.fromEntries( Object.entries(optimisticParams).map(([key]) => [key, null]), ), [startCursorParamName]: optimisticParams[startCursorParamName], [endCursorParamName]: optimisticParams[endCursorParamName], }; setOptimisticParams(nextParams); await setParams(nextParams); }); }} size="small" variant="secondary" > {resetFiltersLabel} </Button> </div> )} </Stream> </div> );}export function FilterPanelSkeleton() { return ( <Skeleton.Root className="group-has-[[data-pending]]/filter-panel:animate-pulse" pending> <div className="space-y-5" data-pending> {Array.from({ length: 3 }).map((_, idx) => ( <div key={idx}> <div className="flex items-start gap-8 py-3 @md:py-4"> <Skeleton.Text characterCount={12} className="flex-1 rounded text-sm" /> <Skeleton.Box className="mt-1 h-4 w-4 rounded" /> </div> <div className="py-3 text-base"> <div className="flex flex-wrap gap-2"> <Skeleton.Box className="h-12 w-[8ch] rounded-full" /> <Skeleton.Box className="h-12 w-[12ch] rounded-full" /> <Skeleton.Box className="h-12 w-[10ch] rounded-full" /> </div> </div> </div> ))} {/* Reset Filters Button */} <Skeleton.Box className="h-10 w-[10ch] rounded-full" /> </div> </Skeleton.Root> );}
sections/product-list-section/filter-parsers.ts
import { parseAsArrayOf, parseAsInteger, parseAsString, ParserBuilder } from 'nuqs/server';import { Filter } from './filter-panel';// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport function getFilterParsers(filters: Filter[]): Record<string, ParserBuilder<any>> { return filters .filter((filter) => filter.type !== 'link-group') .reduce((acc, filter) => { switch (filter.type) { case 'range': return { ...acc, [filter.minParamName]: parseAsInteger, [filter.maxParamName]: parseAsInteger, }; case 'toggle-group': return { ...acc, [filter.paramName]: parseAsArrayOf(parseAsString), }; case 'rating': return { ...acc, [filter.paramName]: parseAsArrayOf(parseAsString), }; default: return { ...acc, }; } }, {});}
sections/product-list-section/sorting.tsx
'use client';import { parseAsString, useQueryState } from 'nuqs';import { useOptimistic, useTransition } from 'react';import { Select } from '@/vibes/soul/form/select';import { Streamable, useStreamable } from '@/vibes/soul/lib/streamable';import * as Skeleton from '@/vibes/soul/primitives/skeleton';export interface Option { label: string; value: string;}export function Sorting({ label: streamableLabel, options: streamableOptions, paramName = 'sort', defaultValue = '', placeholder: streamablePlaceholder,}: { label?: Streamable<string | null>; options: Streamable<Option[]>; paramName?: string; defaultValue?: string; placeholder?: Streamable<string | null>;}) { const [param, setParam] = useQueryState( paramName, parseAsString.withDefault(defaultValue).withOptions({ shallow: false, history: 'push' }), ); const [optimisticParam, setOptimisticParam] = useOptimistic(param); const [isPending, startTransition] = useTransition(); const options = useStreamable(streamableOptions); const label = useStreamable(streamableLabel) ?? 'Sort'; const placeholder = useStreamable(streamablePlaceholder) ?? 'Sort by'; return ( <Select hideLabel label={label} name={paramName} onValueChange={(value) => { startTransition(async () => { setOptimisticParam(value); await setParam(value); }); }} options={options} pending={isPending} placeholder={placeholder} value={optimisticParam} variant="round" /> );}export function SortingSkeleton() { return ( <Skeleton.Root className="@container-normal group-has-[[data-pending]]/product-list-section:animate-pulse" pending > <Skeleton.Box className="h-[50px] w-[12ch] rounded-full" data-pending /> </Skeleton.Root> );}
Usage
function Usage() { return ( );}
API Reference
Prop | Type | Default |
---|---|---|
breadcrumbs | 'Products' | |
title | Streamable <string> | |
totalCount* | Streamable <number> | |
products* | ||
filters* | ||
sortOptions* | ||
compareProducts | ||
paginationInfo | ||
compareAction | ComponentProps<'form'>['action'] | |
compareLabel | Streamable <string> | |
filterLabel | string | |
filtersPanelTitle | Streamable <string> | 'Filters' |
resetFiltersLabel | Streamable <string> | |
rangeFilterApplyLabel | Streamable <string> | |
sortLabel | Streamable <string> | |
sortPlaceholder | Streamable <string> | |
sortParamName | string | |
sortDefaultValue | string | |
compareParamName | string | |
emptyStateSubtitle | Streamable <string> | |
emptyStateTitle | Streamable <string> | |
placeholderCount | number |
Product
Prop | Type | Default |
---|---|---|
id* | string | |
title* | string | |
href* | string | |
image | { src: string; alt: string } | |
price | Price | |
subtitle | string | |
badge | string | |
rating | number |
Breadcrumb
Prop | Type | Default |
---|---|---|
label* | string | |
href* | string |
SortOption
Prop | Type | Default |
---|---|---|
label* | string | |
value* | string |
CursorPaginationInfo
Prop | Type | Default |
---|---|---|
startCursorParamName | string | |
startCursor* | string | null | |
endCursorParamName | string | |
endCursor* | string | null |
Filter
See @/vibes/soul/sections/product-list-section/filter-panel.tsx
for more information on available filter types.
CSS Variables
This component supports various CSS variables for theming. Here's a comprehensive list.
:root { --product-list-section-title: hsl(var(--foreground)); --product-list-section-title-font-family: var(--font-heading); --product-list-section-total-count: hsl(var(--contrast-300)); --product-list-section-filter-label-font-family: var(--font-mono); --product-list-section-filter-label: hsl(var(--contrast-400)); --product-list-section-filter-link: hsl(var(--contrast-500)); --product-list-section-filter-link-hover: hsl(var(--foreground)); --product-list-section-filter-link-font-family: var(--font-body);}