Product Grid Page
blockcommerceE-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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/product-grid-page.json$npx shadcn@latest add https://uipkge.dev/r/lit/product-grid-page.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/product-grid-page.json$bunx shadcn@latest add https://uipkge.dev/r/lit/product-grid-page.jsonManifest:
https://uipkge.dev/r/lit/product-grid-page.jsonInstalls to:src/uip/blocks/product-grid-page/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ProductColorinterface ProductColor {
id: string
name: string
class: string
colorCode: string
}Productinterface 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
}CategoryOptioninterface CategoryOption {
id: string
label: string
}BrandOptioninterface BrandOption {
id: string
label: string
}ColorOptioninterface ColorOption {
id: string
label: string
class: string
}HTMLElementTagNameMapinterface 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