Product listing

A grid of products with filters, a way to sort, a wishlist heart on each, and pages.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the block:

npx ermineui add product-listing
pnpm dlx ermineui add product-listing
yarn dlx ermineui add product-listing
bunx ermineui add product-listing

That copies 3 files into src/components/blocks/product-listing/, and the 7 components it is built from. Files you already have are kept.

Use

import { ProductListing } from '@/components/blocks/product-listing'
<script setup lang="ts">
import { ProductListing } from '@/components/blocks/product-listing'
</script>

<template>
  <ProductListing />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
ProductListing.vuesrc/components/blocks/product-listing/ProductListing.vue
index.tssrc/components/blocks/product-listing/index.ts
sample-data.tssrc/components/blocks/product-listing/sample-data.ts

It is built from: Button, Empty state, Filter bar, Pagination, Product card, Select, Wishlist button. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

ProductListing.vue 202 lines
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Button } from '../../ui/button'
import { EmptyState } from '../../ui/empty-state'
import { FilterBar, facetOptions, type FilterBarFacet, type FilterBarValue } from '../../ui/filter-bar'
import { Pagination } from '../../ui/pagination'
import { ProductCard } from '../../ui/product-card'
import { Select, SelectContent, SelectItem, SelectTrigger } from '../../ui/select'
import { WishlistButton } from '../../ui/wishlist-button'
import { sampleProducts, type ListingProduct, type ListingSort } from './sample-data'

// Everything shown comes in through these props. The defaults are made-up products from sample-data.ts.
const props = withDefaults(
  defineProps<{
    products?: readonly ListingProduct[]
    title?: string
    currency?: string
    locale?: string
    /** How many products make a page. */
    pageSize?: number
    /** Heading level of the title. Product names are one level below it. */
    titleAs?: 'h1' | 'h2' | 'h3'
  }>(),
  { products: () => sampleProducts, title: 'All products', currency: 'USD', locale: 'en-US', pageSize: 6, titleAs: 'h2' },
)

const emit = defineEmits<{
  /** "Add to cart" was pressed on a product. */
  addToCart: [product: ListingProduct]
}>()

/** The ids of the products on the reader's wishlist. */
const saved = defineModel<string[]>('saved', { default: () => [] })
/** For each filter, the values that are ticked. */
const filters = defineModel<FilterBarValue>('filters', { default: () => ({}) })
const sort = defineModel<ListingSort>('sort', { default: 'featured' })
const page = defineModel<number>('page', { default: 1 })

const heading = ref<HTMLElement | null>(null)

// The filters are whatever brands and categories the products have, each with how many there are.
const facets = computed<FilterBarFacet[]>(() => [
  { key: 'brand', label: 'Brand', options: facetOptions(props.products, 'brand') },
  { key: 'category', label: 'Category', options: facetOptions(props.products, 'category') },
])

const filtered = computed(() =>
  props.products.filter((product) => facets.value.every((facet) => !filters.value[facet.key]?.length || filters.value[facet.key]!.includes(product[facet.key as 'brand' | 'category']))),
)

const sorted = computed(() => {
  const list = [...filtered.value]
  if (sort.value === 'price-asc') list.sort((a, b) => a.price - b.price)
  else if (sort.value === 'price-desc') list.sort((a, b) => b.price - a.price)
  else if (sort.value === 'rating') list.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))
  return list
})

const pageCount = computed(() => Math.max(1, Math.ceil(sorted.value.length / props.pageSize)))
const shown = computed(() => sorted.value.slice((page.value - 1) * props.pageSize, page.value * props.pageSize))
const productHeading = computed(() => `h${Number(props.titleAs[1]) + 1}`)

// A different set of products starts again from its first page.
watch([filters, sort], () => {
  page.value = 1
})

function toggleSaved(id: string, on: boolean) {
  saved.value = on ? [...saved.value, id] : saved.value.filter((other) => other !== id)
}

async function goTo(next: number) {
  page.value = next
  // The products have all changed: back to the top of the list, with focus on its heading so reading starts there.
  await nextTick()
  heading.value?.focus({ preventScroll: true })
  heading.value?.scrollIntoView({ block: 'nearest' })
}
</script>

<template>
  <section class="er-product-listing">
    <div class="er-product-listing-header">
      <component :is="titleAs" ref="heading" class="er-product-listing-title" tabindex="-1">{{ title }}</component>
      <!-- Announced when filters change, so a screen reader user hears that the list is different. -->
      <p class="er-product-listing-count" role="status">{{ sorted.length }} {{ sorted.length === 1 ? 'product' : 'products' }}</p>
    </div>

    <div class="er-product-listing-toolbar">
      <FilterBar v-model="filters" :facets="facets" />
      <Select v-model="sort">
        <SelectTrigger class="er-product-listing-sort" aria-label="Sort by" />
        <SelectContent>
          <SelectItem value="featured">Featured</SelectItem>
          <SelectItem value="price-asc">Price: low to high</SelectItem>
          <SelectItem value="price-desc">Price: high to low</SelectItem>
          <SelectItem value="rating">Top rated</SelectItem>
        </SelectContent>
      </Select>
    </div>

    <EmptyState v-if="!sorted.length" title="Nothing matches those filters" description="Try taking one off.">
      <Button variant="outline" @click="filters = {}">Clear filters</Button>
    </EmptyState>

    <ul v-else class="er-product-listing-grid">
      <li v-for="product in shown" :key="product.id">
        <ProductCard
          :title="product.title"
          :href="product.href"
          :image="product.image"
          :brand="product.brand"
          :price="product.price"
          :compare-at="product.compareAt"
          :rating="product.rating"
          :reviews="product.reviews"
          :badge="product.badge"
          :currency="currency"
          :locale="locale"
          :title-as="productHeading"
        >
          <template #action>
            <WishlistButton :model-value="saved.includes(product.id)" size="sm" :label="`Save ${product.title} for later`" @update:model-value="toggleSaved(product.id, $event)" />
          </template>
          <Button variant="outline" size="sm" @click="emit('addToCart', product)">Add<span class="er-product-listing-hidden"> {{ product.title }}</span> to cart</Button>
        </ProductCard>
      </li>
    </ul>

    <Pagination v-if="pageCount > 1" :model-value="page" :page-count="pageCount" class="er-product-listing-pages" @update:model-value="goTo" />
  </section>
</template>

<style>
@layer components {
  .er-product-listing {
    display: grid;
    gap: 1.25rem;
    width: 100%;
    min-width: 0;
  }

  .er-product-listing-header {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.25rem 0.75rem;
  }

  .er-product-listing-title {
    margin: 0;
    font-size: 1.25rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.75rem;
    outline: none;
    /* Clear of a bar stuck to the top of the page when the list is scrolled back to. */
    scroll-margin-top: 5rem;
  }

  .er-product-listing-count {
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
  }

  .er-product-listing-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0.75rem;
  }

  .er-product-listing-toolbar .er-product-listing-sort {
    width: auto;
    min-width: 11rem;
  }

  .er-product-listing-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
    gap: 2rem 1rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .er-product-listing-pages {
    justify-self: center;
  }

  .er-product-listing-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
index.ts 4 lines
/** Product listing — a grid of products with filters, a way to sort, a wishlist heart on each, and pages. */
export { default as ProductListing } from './ProductListing.vue'
export * from './sample-data'
sample-data.ts 48 lines
/**
 * Made-up products so the listing has something to show. Replace this file with your own data,
 * or pass your own through the props of ProductListing.vue.
 */

export interface ListingProduct {
  id: string
  title: string
  /** Where the product's own page is. */
  href?: string
  image: string
  /** Maker or collection. One of the things the list can be filtered by. */
  brand: string
  /** What kind of thing it is. The other thing the list can be filtered by. */
  category: string
  /** In whole units of the currency. */
  price: number
  /** The price before a reduction. */
  compareAt?: number
  /** Average score out of five. */
  rating?: number
  /** How many reviews the score is from. */
  reviews?: number
  /** A short label over the corner of the picture, e.g. "New". */
  badge?: string
}

export type ListingSort = 'featured' | 'price-asc' | 'price-desc' | 'rating'

// Stand-in pictures drawn as SVG, so the block needs no image files and no network.
const picture = (background: string, shape: string) =>
  `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><rect width="120" height="120" fill="${background}"/><rect x="34" y="28" width="52" height="64" rx="10" fill="${shape}"/></svg>`,
  )}`

export const sampleProducts: ListingProduct[] = [
  { id: 'mug', title: 'Stoneware mug', brand: 'Hearth & Kiln', category: 'Kitchen', price: 24, compareAt: 30, rating: 4.6, reviews: 128, badge: 'Sale', image: picture('#efe7da', '#c9b79c') },
  { id: 'throw', title: 'Wool throw', brand: 'Northfold', category: 'Living', price: 96, rating: 4.8, reviews: 64, image: picture('#dde5d6', '#6f8a5b') },
  { id: 'lamp', title: 'Desk lamp', brand: 'Lumen Works', category: 'Lighting', price: 148.5, rating: 4.3, reviews: 41, badge: 'New', image: picture('#dfe3ea', '#2f3a4f') },
  { id: 'bowl', title: 'Serving bowl', brand: 'Hearth & Kiln', category: 'Kitchen', price: 42, rating: 4.7, reviews: 87, image: picture('#f1e4dc', '#b9735a') },
  { id: 'cushion', title: 'Linen cushion', brand: 'Northfold', category: 'Living', price: 38, rating: 4.1, reviews: 23, image: picture('#e9e4d8', '#a39a7f') },
  { id: 'pendant', title: 'Pendant light', brand: 'Lumen Works', category: 'Lighting', price: 210, compareAt: 260, rating: 4.9, reviews: 19, badge: 'Sale', image: picture('#e3e0ea', '#5b4f7a') },
  { id: 'jug', title: 'Water jug', brand: 'Hearth & Kiln', category: 'Kitchen', price: 56, rating: 4.4, reviews: 52, image: picture('#dfe9ea', '#4f7a80') },
  { id: 'rug', title: 'Flatweave rug', brand: 'Northfold', category: 'Living', price: 320, rating: 4.5, reviews: 34, image: picture('#ebdfd6', '#8a5b4a') },
  { id: 'candle', title: 'Beeswax candles', brand: 'Lumen Works', category: 'Lighting', price: 18, rating: 4.2, reviews: 210, image: picture('#f3ecd6', '#d1a93c') },
  { id: 'board', title: 'Oak chopping board', brand: 'Hearth & Kiln', category: 'Kitchen', price: 64, rating: 4.8, reviews: 73, badge: 'New', image: picture('#efe3d0', '#9c7447') },
]