Menu

Change language

GitHub repo

Product Grid Page

blockcommerce

E-commerce product listing page (PLP) featuring breadcrumb navigation, category header with sorting and mobile filter drawer, a 2-column layout with a multi-facet sidebar (categories, price range slider, color swatches, brands, ratings), and a responsive 3-column product grid with image hover zoom, badges, wishlist toggle, color swatches, quick add to cart, and pagination.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/product-grid-page.json
Manifest:https://uipkge.dev/r/lit/product-grid-page.jsonInstalls to:src/uip/blocks/product-grid-page/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ProductColor
interface ProductColor {
  id: string
  name: string
  class: string
  colorCode: string
}
Product
interface Product {
  id: string
  name: string
  brand: string
  category: string
  categoryId: string
  price: number
  originalPrice?: number
  rating: number
  reviewCount: number
  badge?: {
    label: string
    variant: 'default' | 'secondary' | 'destructive' | 'outline'
  }
  image: string
  colors: ProductColor[]
  createdAt: number
  featuredRank: number
}
CategoryOption
interface CategoryOption {
  id: string
  label: string
}
BrandOption
interface BrandOption {
  id: string
  label: string
}
ColorOption
interface ColorOption {
  id: string
  label: string
  class: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-product-grid-page': UipProductGridPage
  }
}

Files installed (1)

  • src/uip/blocks/product-grid-page/product-grid-page.ts36.3 kB
    import { LitElement, css, html, nothing } from 'lit'
    import {
      Check,
      ChevronLeft,
      ChevronRight,
      Heart,
      RotateCcw,
      ShoppingBag,
      SlidersHorizontal,
      Star,
      X,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/breadcrumb/breadcrumb'
    import '../../components/button/button'
    import '../../components/select/select'
    import type { UipSelect } from '../../components/select/select'
    import '../../components/separator/separator'
    import '../../components/sheet/sheet'
    import '../../components/checkbox/checkbox'
    import '../../components/slider/slider'
    
    interface ProductColor {
      id: string
      name: string
      class: string
      colorCode: string
    }
    
    interface Product {
      id: string
      name: string
      brand: string
      category: string
      categoryId: string
      price: number
      originalPrice?: number
      rating: number
      reviewCount: number
      badge?: {
        label: string
        variant: 'default' | 'secondary' | 'destructive' | 'outline'
      }
      image: string
      colors: ProductColor[]
      createdAt: number
      featuredRank: number
    }
    
    export interface CategoryOption {
      id: string
      label: string
    }
    
    export interface BrandOption {
      id: string
      label: string
    }
    
    export interface ColorOption {
      id: string
      label: string
      class: string
    }
    
    export const CATEGORIES: CategoryOption[] = [
      { id: 'sneakers', label: 'Sneakers' },
      { id: 'running', label: 'Running' },
      { id: 'boots', label: 'Boots' },
      { id: 'loafers', label: 'Loafers' },
    ]
    
    export const BRANDS: BrandOption[] = [
      { id: 'Nike', label: 'Nike' },
      { id: 'Adidas', label: 'Adidas' },
      { id: 'New Balance', label: 'New Balance' },
      { id: 'Salomon', label: 'Salomon' },
    ]
    
    export const COLORS: ColorOption[] = [
      { id: 'black', label: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900 border-zinc-800' },
      { id: 'white', label: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border-zinc-300' },
      { id: 'sage', label: 'Sage', class: 'bg-chart-2 border-chart-2' },
      { id: 'navy', label: 'Navy', class: 'bg-slate-900 border-slate-800' },
      { id: 'amber', label: 'Amber', class: 'bg-warning border-warning' },
    ]
    
    export const RATING_OPTIONS = [
      { value: 4.8, label: '4.8 stars & above' },
      { value: 4.5, label: '4.5 stars & above' },
      { value: 4.0, label: '4.0 stars & above' },
    ]
    
    const PRODUCTS: Product[] = [
      {
        id: 'prod-1',
        name: 'XT-6 GORE-TEX Trail Running Shoe',
        brand: 'Salomon',
        category: 'Running',
        categoryId: 'running',
        price: 220,
        originalPrice: 245,
        rating: 4.9,
        reviewCount: 128,
        badge: { label: 'Best Seller', variant: 'default' },
        image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c1', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c2', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
          { id: 'c3', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
        ],
        createdAt: 1708000000000,
        featuredRank: 1,
      },
      {
        id: 'prod-2',
        name: 'Air Max Pulse Premium',
        brand: 'Nike',
        category: 'Sneakers',
        categoryId: 'sneakers',
        price: 150,
        originalPrice: 180,
        rating: 4.8,
        reviewCount: 94,
        badge: { label: '-20% OFF', variant: 'destructive' },
        image: 'https://images.unsplash.com/photo-1552346154-21d32810aba3?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c4', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
          { id: 'c5', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c6', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
        ],
        createdAt: 1708500000000,
        featuredRank: 2,
      },
      {
        id: 'prod-3',
        name: '990v6 Made in USA Core Sneaker',
        brand: 'New Balance',
        category: 'Sneakers',
        categoryId: 'sneakers',
        price: 200,
        rating: 4.9,
        reviewCount: 215,
        badge: { label: 'New', variant: 'secondary' },
        image: 'https://images.unsplash.com/photo-1539185441755-769473a23570?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c7', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
          { id: 'c8', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
          { id: 'c9', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
        ],
        createdAt: 1709000000000,
        featuredRank: 3,
      },
      {
        id: 'prod-4',
        name: 'Ultraboost Light Performance Runner',
        brand: 'Adidas',
        category: 'Running',
        categoryId: 'running',
        price: 190,
        originalPrice: 210,
        rating: 4.7,
        reviewCount: 86,
        image: 'https://images.unsplash.com/photo-1587563871167-1ee9c731aefb?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c10', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c11', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
        ],
        createdAt: 1707000000000,
        featuredRank: 4,
      },
      {
        id: 'prod-5',
        name: 'Tor Ultra Hi Waterproof Boot',
        brand: 'Salomon',
        category: 'Boots',
        categoryId: 'boots',
        price: 260,
        rating: 4.8,
        reviewCount: 62,
        badge: { label: 'Trending', variant: 'outline' },
        image: 'https://images.unsplash.com/photo-1608231387042-66d1773070a5?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c12', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c13', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
        ],
        createdAt: 1708800000000,
        featuredRank: 5,
      },
      {
        id: 'prod-6',
        name: 'Penny Lug Leather Loafer',
        brand: 'Adidas',
        category: 'Loafers',
        categoryId: 'loafers',
        price: 160,
        originalPrice: 190,
        rating: 4.5,
        reviewCount: 41,
        badge: { label: 'Sale', variant: 'destructive' },
        image: 'https://images.unsplash.com/photo-1533867617858-e7b97e060509?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c14', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c15', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
        ],
        createdAt: 1706000000000,
        featuredRank: 6,
      },
      {
        id: 'prod-7',
        name: 'Fresh Foam X Trail 574 v2',
        brand: 'New Balance',
        category: 'Running',
        categoryId: 'running',
        price: 135,
        originalPrice: 155,
        rating: 4.6,
        reviewCount: 78,
        image: 'https://images.unsplash.com/photo-1595950653106-6bf12165a8df?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c16', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
          { id: 'c17', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
          { id: 'c18', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
        ],
        createdAt: 1707500000000,
        featuredRank: 7,
      },
      {
        id: 'prod-8',
        name: 'Tech Fleece Runner Low Boot',
        brand: 'Nike',
        category: 'Boots',
        categoryId: 'boots',
        price: 185,
        rating: 4.7,
        reviewCount: 53,
        badge: { label: 'New', variant: 'secondary' },
        image: 'https://images.unsplash.com/photo-1514989940723-e8e51635b782?w=700&auto=format&fit=crop&q=80',
        colors: [
          { id: 'c19', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
          { id: 'c20', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
          { id: 'c21', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
        ],
        createdAt: 1708900000000,
        featuredRank: 8,
      },
    ]
    
    /**
     * <uip-product-grid-page> — the registry ProductGridPage block as a web component.
     *
     * Class strings and catalog data are React's verbatim, including the filter
     * sidebar (ported as an internal section — React's ProductFilterSidebar is a
     * file of this same block, not a separate install unit). React state becomes
     * { state: true } fields. The block takes no props and fires no events;
     * faceted filtering, sorting, wishlist, quick-add, and pagination are internal.
     */
    export class UipProductGridPage extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        selectedCategories: { state: true },
        priceRange: { state: true },
        selectedColors: { state: true },
        selectedBrands: { state: true },
        minRating: { state: true },
        sortBy: { state: true },
        wishlistIds: { state: true },
        addedToCartIds: { state: true },
        activeProductColors: { state: true },
        page: { state: true },
        isMobileFilterOpen: { state: true },
      }
    
      private selectedCategories: string[] = []
      private priceRange: number[] = [50, 300]
      private selectedColors: string[] = []
      private selectedBrands: string[] = []
      private minRating = 0
      private sortBy = 'featured'
      private wishlistIds: string[] = ['prod-1', 'prod-5']
      private addedToCartIds: string[] = []
      private activeProductColors: Record<string, string> = {}
      private page = 1
      private isMobileFilterOpen = false
      private cartTimer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'product-grid-page')
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.cartTimer) window.clearTimeout(this.cartTimer)
      }
    
      private toggleCategory(id: string, checked: boolean) {
        this.selectedCategories = checked
          ? [...this.selectedCategories, id]
          : this.selectedCategories.filter((item) => item !== id)
        this.page = 1
      }
    
      private toggleBrand(id: string, checked: boolean) {
        this.selectedBrands = checked ? [...this.selectedBrands, id] : this.selectedBrands.filter((item) => item !== id)
        this.page = 1
      }
    
      private toggleColor(id: string) {
        this.selectedColors = this.selectedColors.includes(id)
          ? this.selectedColors.filter((item) => item !== id)
          : [...this.selectedColors, id]
        this.page = 1
      }
    
      private setRating(val: number) {
        this.minRating = this.minRating === val ? 0 : val
        this.page = 1
      }
    
      private toggleWishlist(id: string) {
        this.wishlistIds = this.wishlistIds.includes(id)
          ? this.wishlistIds.filter((item) => item !== id)
          : [...this.wishlistIds, id]
      }
    
      private addToCart(id: string) {
        if (this.addedToCartIds.includes(id)) return
        this.addedToCartIds = [...this.addedToCartIds, id]
        if (this.cartTimer) window.clearTimeout(this.cartTimer)
        this.cartTimer = window.setTimeout(() => {
          this.addedToCartIds = this.addedToCartIds.filter((item) => item !== id)
        }, 1600)
      }
    
      private selectProductColor(productId: string, colorCode: string) {
        this.activeProductColors = { ...this.activeProductColors, [productId]: colorCode }
      }
    
      private resetFilters() {
        this.selectedCategories = []
        this.priceRange = [50, 300]
        this.selectedColors = []
        this.selectedBrands = []
        this.minRating = 0
        this.page = 1
      }
    
      private get hasActiveFilters() {
        return (
          this.selectedCategories.length > 0 ||
          this.selectedBrands.length > 0 ||
          this.selectedColors.length > 0 ||
          this.minRating > 0 ||
          this.priceRange[0]! > 50 ||
          this.priceRange[1]! < 300
        )
      }
    
      private get activeFilterCount() {
        return (
          this.selectedCategories.length +
          this.selectedBrands.length +
          this.selectedColors.length +
          (this.minRating > 0 ? 1 : 0) +
          (this.priceRange[0]! > 50 || this.priceRange[1]! < 300 ? 1 : 0)
        )
      }
    
      private get filteredProducts(): Product[] {
        return PRODUCTS.filter((product) => {
          if (this.selectedCategories.length > 0 && !this.selectedCategories.includes(product.categoryId)) return false
          if (product.price < this.priceRange[0]! || product.price > this.priceRange[1]!) return false
          if (this.selectedColors.length > 0) {
            if (!product.colors.some((c) => this.selectedColors.includes(c.colorCode))) return false
          }
          if (this.selectedBrands.length > 0 && !this.selectedBrands.includes(product.brand)) return false
          if (this.minRating > 0 && product.rating < this.minRating) return false
          return true
        }).sort((a, b) => {
          if (this.sortBy === 'price-asc') return a.price - b.price
          if (this.sortBy === 'price-desc') return b.price - a.price
          if (this.sortBy === 'rating-desc') return b.rating - a.rating
          if (this.sortBy === 'newest') return b.createdAt - a.createdAt
          return a.featuredRank - b.featuredRank
        })
      }
    
      private renderFilterSidebar(idPrefix: string) {
        return html`<div class="space-y-6">
          <div class="space-y-3">
            <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Category</h3>
            <div class="space-y-2.5">
              ${CATEGORIES.map(
                (cat) => html`<div class="flex items-center justify-between">
                  <label for=${`${idPrefix}cat-${cat.id}`} class="text-foreground flex cursor-pointer items-center gap-2 text-sm">
                    <uip-checkbox
                      id=${`${idPrefix}cat-${cat.id}`}
                      .checked=${this.selectedCategories.includes(cat.id)}
                      @checked-change=${(e: CustomEvent<{ checked: boolean }>) =>
                        this.toggleCategory(cat.id, e.detail.checked)}
                    ></uip-checkbox>
                    <span>${cat.label}</span>
                  </label>
                  <span class="text-muted-foreground text-xs tabular-nums"
                    >${PRODUCTS.filter((p) => p.categoryId === cat.id).length}</span
                  >
                </div>`,
              )}
            </div>
          </div>
    
          <uip-separator></uip-separator>
    
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Price Range</h3>
              <span class="text-foreground text-xs font-medium tabular-nums">$${this.priceRange[0]} – $${this.priceRange[1]}</span>
            </div>
            <uip-slider
              .value=${this.priceRange}
              range
              min="50"
              max="300"
              step="5"
              class="py-2"
              @value-change=${(e: CustomEvent<number[]>) => {
                this.priceRange = e.detail
                this.page = 1
              }}
            ></uip-slider>
            <div class="text-muted-foreground flex items-center justify-between text-xs">
              <span>$50</span>
              <span>$300</span>
            </div>
          </div>
    
          <uip-separator></uip-separator>
    
          <div class="space-y-3">
            <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Color</h3>
            <div class="flex flex-wrap gap-2">
              ${COLORS.map(
                (color) => html`<button
                  type="button"
                  title=${color.label}
                  class=${cn(
                    'focus-visible:ring-ring relative flex size-7 items-center justify-center rounded-full border transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none',
                    color.class,
                    this.selectedColors.includes(color.id)
                      ? 'ring-primary ring-offset-background ring-2 ring-offset-2'
                      : 'border-border',
                  )}
                  @click=${() => this.toggleColor(color.id)}
                >
                  ${this.selectedColors.includes(color.id)
                    ? html`${icon(Check, 'check', cn('size-3.5', color.id === 'white' ? 'text-zinc-950' : 'text-white'))}`
                    : nothing}
                  <span class="sr-only">${color.label}</span>
                </button>`,
              )}
            </div>
          </div>
    
          <uip-separator></uip-separator>
    
          <div class="space-y-3">
            <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Brand</h3>
            <div class="space-y-2.5">
              ${BRANDS.map(
                (b) => html`<div class="flex items-center justify-between">
                  <label for=${`${idPrefix}brand-${b.id}`} class="text-foreground flex cursor-pointer items-center gap-2 text-sm">
                    <uip-checkbox
                      id=${`${idPrefix}brand-${b.id}`}
                      .checked=${this.selectedBrands.includes(b.id)}
                      @checked-change=${(e: CustomEvent<{ checked: boolean }>) => this.toggleBrand(b.id, e.detail.checked)}
                    ></uip-checkbox>
                    <span>${b.label}</span>
                  </label>
                  <span class="text-muted-foreground text-xs tabular-nums"
                    >${PRODUCTS.filter((p) => p.brand === b.id).length}</span
                  >
                </div>`,
              )}
            </div>
          </div>
    
          <uip-separator></uip-separator>
    
          <div class="space-y-3">
            <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Customer Rating</h3>
            <div class="space-y-1.5">
              ${RATING_OPTIONS.map(
                (opt) => html`<button
                  type="button"
                  class=${cn(
                    'hover:bg-muted/60 flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs transition-colors',
                    this.minRating === opt.value ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground',
                  )}
                  @click=${() => this.setRating(opt.value)}
                >
                  <div class="flex items-center gap-1.5">
                    ${icon(Star, 'star', 'fill-warning text-warning size-3.5')}
                    <span>${opt.label}</span>
                  </div>
                  ${this.minRating === opt.value ? icon(Check, 'check', 'text-primary size-3.5') : nothing}
                </button>`,
              )}
            </div>
          </div>
        </div>`
      }
    
      render() {
        const filteredProducts = this.filteredProducts
        const totalPages = 3
        return html`
          <div data-slot="product-grid-page" class="w-full space-y-8">
            <uip-breadcrumb>
              <uip-breadcrumb-item>
                <uip-breadcrumb-link href="#" class="inline-flex min-h-6 items-center">Home</uip-breadcrumb-link>
              </uip-breadcrumb-item>
              <uip-breadcrumb-separator></uip-breadcrumb-separator>
              <uip-breadcrumb-item>
                <uip-breadcrumb-link href="#" class="inline-flex min-h-6 items-center">Shop</uip-breadcrumb-link>
              </uip-breadcrumb-item>
              <uip-breadcrumb-separator></uip-breadcrumb-separator>
              <uip-breadcrumb-item>
                <uip-breadcrumb-page>Footwear</uip-breadcrumb-page>
              </uip-breadcrumb-item>
            </uip-breadcrumb>
    
            <div class="border-border flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between">
              <div class="space-y-1">
                <div class="flex items-center gap-3">
                  <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">All Footwear</h1>
                  <uip-badge variant="secondary" class="font-normal tabular-nums">${filteredProducts.length} items</uip-badge>
                </div>
                <p class="text-muted-foreground text-xs sm:text-sm">
                  Engineered for performance, trail endurance, and everyday urban utility.
                </p>
              </div>
    
              <div class="flex items-center gap-2">
                <uip-button
                  variant="outline"
                  size="sm"
                  class="gap-2 md:hidden"
                  @click=${() => {
                    this.isMobileFilterOpen = true
                  }}
                >
                  ${icon(SlidersHorizontal, 'sliders-horizontal', 'size-4')}
                  <span>Filters</span>
                  ${this.activeFilterCount > 0
                    ? html`<uip-badge variant="secondary" class="ml-1 px-1.5 py-0 text-xs">${this.activeFilterCount}</uip-badge>`
                    : nothing}
                </uip-button>
    
                <div class="flex items-center gap-2">
                  <span class="text-muted-foreground hidden text-xs sm:inline-block">Sort by:</span>
                  <uip-select
                    class="w-44 text-xs sm:text-sm"
                    .value=${this.sortBy}
                    @change=${(e: Event) => {
                      this.sortBy = (e.target as UipSelect).value
                    }}
                  >
                    <option value="featured">Featured</option>
                    <option value="price-asc">Price: Low to High</option>
                    <option value="price-desc">Price: High to Low</option>
                    <option value="rating-desc">Highest Rated</option>
                    <option value="newest">Newest</option>
                  </uip-select>
                </div>
              </div>
            </div>
    
            <div class="grid grid-cols-1 gap-8 md:grid-cols-[240px_1fr] lg:grid-cols-[260px_1fr]">
              <aside class="hidden space-y-6 md:block" aria-label="Product filters">
                <div class="flex items-center justify-between">
                  <h2 class="text-foreground text-sm font-semibold tracking-tight">Filters</h2>
                  ${this.hasActiveFilters
                    ? html`<uip-button
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
                        @click=${() => this.resetFilters()}
                      >
                        Reset all
                      </uip-button>`
                    : nothing}
                </div>
                <uip-separator></uip-separator>
                ${this.renderFilterSidebar('')}
              </aside>
    
              <section class="min-w-0 space-y-6">
                ${this.hasActiveFilters
                  ? html`<div class="flex flex-wrap items-center gap-2">
                      ${this.selectedCategories.map(
                        (catId) => html`<uip-badge variant="secondary" class="gap-1.5 py-1 text-xs">
                          <span>Category: ${CATEGORIES.find((c) => c.id === catId)?.label}</span>
                          <button
                            type="button"
                            class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                            aria-label="Remove category filter"
                            @click=${() => this.toggleCategory(catId, false)}
                          >
                            ${icon(X, 'x', 'size-3')}
                          </button>
                        </uip-badge>`,
                      )}
                      ${this.selectedBrands.map(
                        (brand) => html`<uip-badge variant="secondary" class="gap-1.5 py-1 text-xs">
                          <span>Brand: ${brand}</span>
                          <button
                            type="button"
                            class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                            aria-label="Remove brand filter"
                            @click=${() => this.toggleBrand(brand, false)}
                          >
                            ${icon(X, 'x', 'size-3')}
                          </button>
                        </uip-badge>`,
                      )}
                      ${this.selectedColors.map(
                        (colorId) => html`<uip-badge variant="secondary" class="gap-1.5 py-1 text-xs">
                          <span>Color: ${COLORS.find((c) => c.id === colorId)?.label}</span>
                          <button
                            type="button"
                            class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                            aria-label="Remove color filter"
                            @click=${() => this.toggleColor(colorId)}
                          >
                            ${icon(X, 'x', 'size-3')}
                          </button>
                        </uip-badge>`,
                      )}
                      ${this.minRating > 0
                        ? html`<uip-badge variant="secondary" class="gap-1.5 py-1 text-xs">
                            <span>Rating: ${this.minRating}+ Stars</span>
                            <button
                              type="button"
                              class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                              aria-label="Remove rating filter"
                              @click=${() => {
                                this.minRating = 0
                              }}
                            >
                              ${icon(X, 'x', 'size-3')}
                            </button>
                          </uip-badge>`
                        : nothing}
                      ${this.priceRange[0]! > 50 || this.priceRange[1]! < 300
                        ? html`<uip-badge variant="secondary" class="gap-1.5 py-1 text-xs">
                            <span>Price: $${this.priceRange[0]} - $${this.priceRange[1]}</span>
                            <button
                              type="button"
                              class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                              aria-label="Reset price filter"
                              @click=${() => {
                                this.priceRange = [50, 300]
                              }}
                            >
                              ${icon(X, 'x', 'size-3')}
                            </button>
                          </uip-badge>`
                        : nothing}
                      <uip-button
                        variant="link"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-auto px-1 text-xs"
                        @click=${() => this.resetFilters()}
                      >
                        Clear all
                      </uip-button>
                    </div>`
                  : nothing}
    
                ${filteredProducts.length === 0
                  ? html`<div class="border-border flex flex-col items-center justify-center rounded-xl border border-dashed py-16 text-center">
                      <div class="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-full">
                        ${icon(RotateCcw, 'rotate-ccw', 'size-6')}
                      </div>
                      <h3 class="text-foreground mt-4 text-base font-semibold">No matching footwear found</h3>
                      <p class="text-muted-foreground mt-1 max-w-sm text-xs sm:text-sm">
                        Try adjusting your price range, clearing selected colors, or resetting your filters to see more results.
                      </p>
                      <uip-button variant="outline" size="sm" class="mt-4 gap-2" @click=${() => this.resetFilters()}>
                        ${icon(RotateCcw, 'rotate-ccw', 'size-3.5')}
                        <span>Reset filters</span>
                      </uip-button>
                    </div>`
                  : html`<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
                      ${filteredProducts.map(
                        (product) => html`<div
                          class="group border-border bg-card hover:border-border/80 relative flex flex-col overflow-hidden rounded-xl border p-3 shadow-xs transition-colors duration-200 hover:shadow-md"
                        >
                          <div class="bg-muted/40 relative aspect-square w-full overflow-hidden rounded-lg">
                            <img
                              src=${product.image}
                              alt=${product.name}
                              class="h-full w-full object-cover object-center transition-transform duration-500 ease-out group-hover:scale-105"
                              loading="lazy"
                            />
                            ${product.badge
                              ? html`<div class="absolute top-2.5 left-2.5 z-10">
                                  <uip-badge variant=${product.badge.variant} class="text-xs shadow-xs"
                                    >${product.badge.label}</uip-badge
                                  >
                                </div>`
                              : nothing}
                            <button
                              type="button"
                              aria-label="Toggle wishlist"
                              class=${cn(
                                'bg-background/80 hover:bg-background text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2.5 right-2.5 z-10 flex size-8 items-center justify-center rounded-full shadow-xs backdrop-blur-xs transition-colors focus-visible:ring-2 focus-visible:outline-none',
                                this.wishlistIds.includes(product.id) && 'text-destructive hover:text-destructive',
                              )}
                              @click=${(e: Event) => {
                                e.stopPropagation()
                                this.toggleWishlist(product.id)
                              }}
                            >
                              ${icon(
                                Heart,
                                'heart',
                                cn('size-4 transition-transform active:scale-90', this.wishlistIds.includes(product.id) && 'fill-current'),
                              )}
                            </button>
                            <div
                              class="absolute inset-x-2.5 bottom-2.5 z-10 opacity-100 sm:translate-y-2 sm:opacity-0 sm:transition-colors sm:duration-200 sm:group-hover:translate-y-0 sm:group-hover:opacity-100"
                            >
                              <uip-button
                                size="sm"
                                class="w-full gap-1.5 text-xs font-medium shadow-sm"
                                variant="default"
                                @click=${(e: Event) => {
                                  e.stopPropagation()
                                  this.addToCart(product.id)
                                }}
                              >
                                ${this.addedToCartIds.includes(product.id)
                                  ? html`${icon(Check, 'check', 'size-3.5')}<span>Added to Cart</span>`
                                  : html`${icon(ShoppingBag, 'shopping-bag', 'size-3.5')}<span>Quick Add</span>`}
                              </uip-button>
                            </div>
                          </div>
    
                          <div class="mt-3 flex flex-1 flex-col justify-between space-y-2">
                            <div>
                              <div class="flex items-center gap-1.5">
                                ${product.colors.map(
                                  (color) => html`<button
                                    type="button"
                                    title=${color.name}
                                    class=${cn(
                                      'size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none',
                                      color.class,
                                      this.activeProductColors[product.id] === color.colorCode
                                        ? 'ring-1.5 ring-primary ring-offset-background ring-offset-1'
                                        : '',
                                    )}
                                    @click=${(e: Event) => {
                                      e.stopPropagation()
                                      this.selectProductColor(product.id, color.colorCode)
                                    }}
                                  >
                                    <span class="sr-only">${color.name}</span>
                                  </button>`,
                                )}
                                <span class="text-muted-foreground ml-1 text-xs">
                                  ${product.colors.length} ${product.colors.length === 1 ? 'color' : 'colors'}
                                </span>
                              </div>
                              <div class="text-muted-foreground mt-2 flex items-center justify-between text-xs">
                                <span>${product.brand} · ${product.category}</span>
                                <div class="flex items-center gap-1">
                                  ${icon(Star, 'star', 'fill-warning text-warning size-3')}
                                  <span class="text-foreground font-medium">${product.rating}</span>
                                  <span class="text-muted-foreground">(${product.reviewCount})</span>
                                </div>
                              </div>
                              <h3
                                class="text-foreground group-hover:text-primary mt-1 line-clamp-1 cursor-pointer text-sm font-medium transition-colors"
                                title=${product.name}
                              >
                                ${product.name}
                              </h3>
                            </div>
                            <div class="flex min-w-0 flex-wrap items-baseline gap-2 pt-1">
                              <span class="text-foreground text-base font-semibold">$${product.price.toFixed(2)}</span>
                              ${product.originalPrice
                                ? html`<span class="text-muted-foreground text-xs line-through"
                                    >$${product.originalPrice.toFixed(2)}</span
                                  >`
                                : nothing}
                              ${product.originalPrice
                                ? html`<span class="text-success text-xs font-medium"
                                    >Save $${(product.originalPrice - product.price).toFixed(2)}</span
                                  >`
                                : nothing}
                            </div>
                          </div>
                        </div>`,
                      )}
                    </div>`}
    
                ${filteredProducts.length > 0
                  ? html`<nav class="border-border flex items-center justify-between border-t pt-6" aria-label="Pagination">
                      <uip-button
                        variant="outline"
                        size="sm"
                        class="gap-1 text-xs"
                        ?disabled=${this.page === 1}
                        @click=${() => {
                          this.page = Math.max(1, this.page - 1)
                        }}
                      >
                        ${icon(ChevronLeft, 'chevron-left', 'size-4')}
                        <span>Previous</span>
                      </uip-button>
                      <div class="flex items-center gap-1">
                        ${[1, 2, 3].map(
                          (n) => html`<uip-button
                            variant=${n === this.page ? 'outline' : 'ghost'}
                            size="sm"
                            class="size-8 p-0 text-xs tabular-nums"
                            aria-current=${n === this.page ? 'page' : nothing}
                            @click=${() => {
                              this.page = n
                            }}
                          >
                            ${n}
                          </uip-button>`,
                        )}
                      </div>
                      <uip-button
                        variant="outline"
                        size="sm"
                        class="gap-1 text-xs"
                        ?disabled=${this.page === totalPages}
                        @click=${() => {
                          this.page = Math.min(totalPages, this.page + 1)
                        }}
                      >
                        <span>Next</span>
                        ${icon(ChevronRight, 'chevron-right', 'size-4')}
                      </uip-button>
                    </nav>`
                  : nothing}
              </section>
            </div>
    
            <uip-sheet
              side="left"
              ?open=${this.isMobileFilterOpen}
              heading="Filters"
              description="Narrow down footwear by category, price, and specs."
              @open-change=${(e: CustomEvent<{ open: boolean }>) => {
                this.isMobileFilterOpen = e.detail.open
              }}
            >
              <uip-sheet-body>
                <div class="mt-6">
                  <div class="flex items-center justify-between">
                    <span class="text-foreground text-sm font-semibold">Filters</span>
                    ${this.hasActiveFilters
                      ? html`<uip-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
                          @click=${() => this.resetFilters()}
                        >
                          Reset all
                        </uip-button>`
                      : nothing}
                  </div>
                  <div class="mt-4">${this.renderFilterSidebar('mobile-')}</div>
                </div>
              </uip-sheet-body>
            </uip-sheet>
          </div>
        `
      }
    }
    
    customElements.get('uip-product-grid-page') || customElements.define('uip-product-grid-page', UipProductGridPage)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-product-grid-page': UipProductGridPage
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/product-grid-page.json