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-svelte@latest add https://uipkge.dev/r/svelte/product-grid-page.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/product-grid-page.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/product-grid-page.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/product-grid-page.jsonManifest:
https://uipkge.dev/r/svelte/product-grid-page.jsonInstalls to:src/lib/components/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.
CategoryOptioninterface CategoryOption {
id: string
label: string
}BrandOptioninterface BrandOption {
id: string
label: string
}ColorOptioninterface ColorOption {
id: string
label: string
class: string
}npm dependencies
Files installed (3)
src/lib/components/blocks/product-grid-page/ProductGridPage.svelte27.7 kB
<script lang="ts"> import { Check, ChevronLeft, ChevronRight, Heart, RotateCcw, ShoppingBag, SlidersHorizontal, Star, X, } from '@lucide/svelte' import { cn } from '$lib/utils' import { Badge } from '$lib/components/ui/badge' import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from '$lib/components/ui/breadcrumb' import { Button } from '$lib/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' import { Separator } from '$lib/components/ui/separator' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '$lib/components/ui/sheet' import ProductFilterSidebar from './ProductFilterSidebar.svelte' import { CATEGORIES, COLORS } from './product-filter-data' 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 } interface Props { class?: string } let { class: className }: Props = $props() 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-6c9ebd614d3a?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, }, ] let selectedCategories: string[] = $state([]) let priceRange: [number, number] = $state([50, 300]) let selectedColors: string[] = $state([]) let selectedBrands: string[] = $state([]) let minRating = $state(0) let sortBy = $state('featured') let wishlistIds: Set<string> = $state(new Set(['prod-1', 'prod-5'])) let addedToCartIds: Set<string> = $state(new Set()) let activeProductColor: Record<string, string> = $state({}) let page = $state(1) const totalPages = 3 let isMobileFilterOpen = $state(false) function toggleCategory(id: string, checked: boolean) { if (checked) { selectedCategories = [...selectedCategories, id] } else { selectedCategories = selectedCategories.filter((item) => item !== id) } page = 1 } function toggleBrand(id: string, checked: boolean) { if (checked) { selectedBrands = [...selectedBrands, id] } else { selectedBrands = selectedBrands.filter((item) => item !== id) } page = 1 } function toggleColor(id: string) { if (selectedColors.includes(id)) { selectedColors = selectedColors.filter((item) => item !== id) } else { selectedColors = [...selectedColors, id] } page = 1 } function setRating(val: number) { if (minRating === val) { minRating = 0 } else { minRating = val } page = 1 } function toggleWishlist(id: string) { const next = new Set(wishlistIds) if (next.has(id)) { next.delete(id) } else { next.add(id) } wishlistIds = next } function isWishlisted(id: string) { return wishlistIds.has(id) } function addToCart(id: string) { const next = new Set(addedToCartIds) next.add(id) addedToCartIds = next setTimeout(() => { const updated = new Set(addedToCartIds) updated.delete(id) addedToCartIds = updated }, 1600) } function isAddedToCart(id: string) { return addedToCartIds.has(id) } function selectProductColor(productId: string, colorCode: string) { activeProductColor = { ...activeProductColor, [productId]: colorCode, } } function resetFilters() { selectedCategories = [] priceRange = [50, 300] selectedColors = [] selectedBrands = [] minRating = 0 page = 1 } const hasActiveFilters = $derived( selectedCategories.length > 0 || selectedBrands.length > 0 || selectedColors.length > 0 || minRating > 0 || priceRange[0] > 50 || priceRange[1] < 300, ) const activeFilterCount = $derived.by(() => { let count = 0 if (selectedCategories.length > 0) count += selectedCategories.length if (selectedBrands.length > 0) count += selectedBrands.length if (selectedColors.length > 0) count += selectedColors.length if (minRating > 0) count += 1 if (priceRange[0] > 50 || priceRange[1] < 300) count += 1 return count }) function getCategoryCount(catId: string) { return PRODUCTS.filter((p) => p.categoryId === catId).length } function getBrandCount(brand: string) { return PRODUCTS.filter((p) => p.brand === brand).length } const filteredProducts = $derived.by(() => { return PRODUCTS.filter((product) => { if (selectedCategories.length > 0 && !selectedCategories.includes(product.categoryId)) { return false } if (product.price < priceRange[0] || product.price > priceRange[1]) { return false } if (selectedColors.length > 0) { const hasMatchingColor = product.colors.some((c) => selectedColors.includes(c.colorCode)) if (!hasMatchingColor) return false } if (selectedBrands.length > 0 && !selectedBrands.includes(product.brand)) { return false } if (minRating > 0 && product.rating < minRating) { return false } return true }).sort((a, b) => { if (sortBy === 'price-asc') return a.price - b.price if (sortBy === 'price-desc') return b.price - a.price if (sortBy === 'rating-desc') return b.rating - a.rating if (sortBy === 'newest') return b.createdAt - a.createdAt return a.featuredRank - b.featuredRank }) }) const pageNumbers = $derived(Array.from({ length: totalPages }, (_, i) => i + 1)) </script> <div data-slot="product-grid-page" class={cn('w-full space-y-8', className)}> <!-- Breadcrumb --> <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="#" class="inline-flex min-h-6 items-center">Home</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink href="#" class="inline-flex min-h-6 items-center">Shop</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Footwear</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> <!-- Header & Toolbar --> <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> <Badge variant="secondary" class="font-normal tabular-nums">{filteredProducts.length} items</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"> <!-- Mobile Filter Drawer Trigger --> <Sheet bind:open={isMobileFilterOpen}> <SheetTrigger> {#snippet child({ props })} <Button variant="outline" size="sm" class="gap-2 md:hidden" {...props}> <SlidersHorizontal class="size-4" /> <span>Filters</span> {#if activeFilterCount > 0} <Badge variant="secondary" class="ml-1 px-1.5 py-0 text-xs"> {activeFilterCount} </Badge> {/if} </Button> {/snippet} </SheetTrigger> <SheetContent side="left" class="w-full max-w-xs overflow-y-auto p-6"> <SheetHeader class="text-left"> <div class="flex items-center justify-between"> <SheetTitle class="text-base font-semibold">Filters</SheetTitle> {#if hasActiveFilters} <Button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal" onclick={resetFilters} > Reset all </Button> {/if} </div> <SheetDescription class="text-xs">Narrow down footwear by category, price, and specs.</SheetDescription> </SheetHeader> <div class="mt-6"> <ProductFilterSidebar isMobile {selectedCategories} {priceRange} {selectedColors} {selectedBrands} {minRating} {getCategoryCount} {getBrandCount} onToggleCategory={toggleCategory} onPriceRangeChange={(v) => (priceRange = v)} onToggleColor={toggleColor} onToggleBrand={toggleBrand} onSelectRating={setRating} /> </div> </SheetContent> </Sheet> <!-- Sort Select --> <div class="flex items-center gap-2"> <span class="text-muted-foreground hidden text-xs sm:inline-block">Sort by:</span> <Select bind:value={sortBy}> <SelectTrigger class="h-9 w-44 text-xs sm:text-sm"> <SelectValue placeholder="Sort by" /> </SelectTrigger> <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-desc">Highest Rated</SelectItem> <SelectItem value="newest">Newest</SelectItem> </SelectContent> </Select> </div> </div> </div> <!-- 2-Column Main Layout --> <div class="grid grid-cols-1 gap-8 md:grid-cols-[240px_1fr] lg:grid-cols-[260px_1fr]"> <!-- Left Desktop Sidebar --> <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> {#if hasActiveFilters} <Button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal" onclick={resetFilters} > Reset all </Button> {/if} </div> <Separator /> <ProductFilterSidebar {selectedCategories} {priceRange} {selectedColors} {selectedBrands} {minRating} {getCategoryCount} {getBrandCount} onToggleCategory={toggleCategory} onPriceRangeChange={(v) => (priceRange = v)} onToggleColor={toggleColor} onToggleBrand={toggleBrand} onSelectRating={setRating} /> </aside> <!-- Right Product Grid Area --> <section class="min-w-0 space-y-6"> <!-- Active Filter Chips --> {#if hasActiveFilters} <div class="flex flex-wrap items-center gap-2"> {#each selectedCategories as catId (catId)} <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" onclick={() => toggleCategory(catId, false)} > <X class="size-3" /> </button> </Badge> {/each} {#each selectedBrands as brand (brand)} <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" onclick={() => toggleBrand(brand, false)} > <X class="size-3" /> </button> </Badge> {/each} {#each selectedColors as colorId (colorId)} <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" onclick={() => toggleColor(colorId)} > <X class="size-3" /> </button> </Badge> {/each} {#if minRating > 0} <Badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Rating: {minRating}+ Stars</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Remove rating filter" onclick={() => (minRating = 0)} > <X class="size-3" /> </button> </Badge> {/if} {#if priceRange[0] > 50 || priceRange[1] < 300} <Badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Price: ${priceRange[0]} - ${priceRange[1]}</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Reset price filter" onclick={() => (priceRange = [50, 300])} > <X class="size-3" /> </button> </Badge> {/if} <Button variant="link" size="sm" class="text-muted-foreground hover:text-foreground h-auto px-1 text-xs" onclick={resetFilters} > Clear all </Button> </div> {/if} <!-- Empty State --> {#if filteredProducts.length === 0} <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"> <RotateCcw class="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> <Button variant="outline" size="sm" class="mt-4 gap-2" onclick={resetFilters}> <RotateCcw class="size-3.5" /> <span>Reset filters</span> </Button> </div> {:else} <!-- 3-Column Product Grid --> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> {#each filteredProducts as product (product.id)} <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" > <!-- Image Aspect Square Container --> <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" /> <!-- Top Left Badge --> {#if product.badge} <div class="absolute top-2.5 left-2.5 z-10"> <Badge variant={product.badge.variant} class="text-xs shadow-xs"> {product.badge.label} </Badge> </div> {/if} <!-- Top Right Wishlist Heart --> <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', isWishlisted(product.id) && 'text-destructive hover:text-destructive', )} onclick={(e) => { e.stopPropagation() toggleWishlist(product.id) }} > <Heart class={cn( 'size-4 transition-transform active:scale-90', isWishlisted(product.id) && 'fill-current', )} /> </button> <!-- Quick Add Button (Visible on hover & focus) --> <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" > <Button size="sm" class="w-full gap-1.5 text-xs font-medium shadow-sm" onclick={(e) => { e.stopPropagation() addToCart(product.id) }} > {#if isAddedToCart(product.id)} <Check class="size-3.5" /> {:else} <ShoppingBag class="size-3.5" /> {/if} <span>{isAddedToCart(product.id) ? 'Added to Cart' : 'Quick Add'}</span> </Button> </div> </div> <!-- Card Body --> <div class="mt-3 flex flex-1 flex-col justify-between space-y-2"> <div> <!-- Color swatches preview --> <div class="flex items-center gap-1.5"> {#each product.colors as color (color.id)} <button type="button" title={color.name} class={cn( 'size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none', color.class, activeProductColor[product.id] === color.colorCode ? 'ring-1.5 ring-primary ring-offset-background ring-offset-1' : '', )} onclick={(e) => { e.stopPropagation() selectProductColor(product.id, color.colorCode) }} > <span class="sr-only">{color.name}</span> </button> {/each} <span class="text-muted-foreground ml-1 text-xs"> {product.colors.length} {product.colors.length === 1 ? 'color' : 'colors'} </span> </div> <!-- Brand & Category --> <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"> <Star class="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> <!-- Title --> <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> <!-- Price Row --> <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> {#if product.originalPrice} <span class="text-muted-foreground text-xs line-through"> ${product.originalPrice.toFixed(2)} </span> {/if} {#if product.originalPrice} <span class="text-success text-xs font-medium"> Save ${(product.originalPrice - product.price).toFixed(2)} </span> {/if} </div> </div> </div> {/each} </div> {/if} <!-- Pagination Footer --> {#if filteredProducts.length > 0} <nav class="border-border flex items-center justify-between border-t pt-6" aria-label="Pagination"> <Button variant="outline" size="sm" class="gap-1 text-xs" disabled={page === 1} onclick={() => (page -= 1)} > <ChevronLeft class="size-4" /> <span>Previous</span> </Button> <div class="flex items-center gap-1"> {#each pageNumbers as n (n)} <Button variant={n === page ? 'outline' : 'ghost'} size="sm" class="size-8 p-0 text-xs tabular-nums" aria-current={n === page ? 'page' : undefined} onclick={() => (page = n)} > {n} </Button> {/each} </div> <Button variant="outline" size="sm" class="gap-1 text-xs" disabled={page === totalPages} onclick={() => (page += 1)} > <span>Next</span> <ChevronRight class="size-4" /> </Button> </nav> {/if} </section> </div> </div>src/lib/components/blocks/product-grid-page/ProductFilterSidebar.svelte6.2 kB
<script lang="ts"> import { Check, Star } from '@lucide/svelte' import { Checkbox } from '$lib/components/ui/checkbox' import { Separator } from '$lib/components/ui/separator' import { Slider } from '$lib/components/ui/slider' import { cn } from '$lib/utils' import { BRANDS, CATEGORIES, COLORS, RATING_OPTIONS } from './product-filter-data' interface Props { isMobile?: boolean selectedCategories: string[] priceRange: [number, number] selectedColors: string[] selectedBrands: string[] minRating: number getCategoryCount: (id: string) => number getBrandCount: (brand: string) => number onToggleCategory: (id: string, checked: boolean) => void onPriceRangeChange: (value: [number, number]) => void onToggleColor: (id: string) => void onToggleBrand: (id: string, checked: boolean) => void onSelectRating: (value: number) => void } let { isMobile = false, selectedCategories, priceRange, selectedColors, selectedBrands, minRating, getCategoryCount, getBrandCount, onToggleCategory, onPriceRangeChange, onToggleColor, onToggleBrand, onSelectRating, }: Props = $props() // svelte-ignore state_referenced_locally: isMobile is set once by the parent layout, never toggled. const idPrefix = isMobile ? 'mobile-' : '' // Slider reports live drags through bind:value only, so mirror the // controlled prop locally: drags flow up via onPriceRangeChange, parent // resets (Reset all) flow back down through the first effect. // svelte-ignore state_referenced_locally: one-shot seed; the effect below re-syncs parent resets. let rangeValue: [number, number] = $state(priceRange) $effect(() => { if (rangeValue[0] !== priceRange[0] || rangeValue[1] !== priceRange[1]) rangeValue = priceRange }) $effect(() => { onPriceRangeChange(rangeValue) }) </script> <div class="space-y-6"> <!-- Categories --> <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"> {#each CATEGORIES as cat (cat.id)} <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" > <Checkbox id={`${idPrefix}cat-${cat.id}`} checked={selectedCategories.includes(cat.id)} onCheckedChange={(v) => onToggleCategory(cat.id, v === true)} /> <span>{cat.label}</span> </label> <span class="text-muted-foreground text-xs tabular-nums">{getCategoryCount(cat.id)}</span> </div> {/each} </div> </div> <Separator /> <!-- Price Range --> <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"> ${rangeValue[0]} – ${rangeValue[1]} </span> </div> <Slider bind:value={rangeValue} min={50} max={300} step={5} range class="py-2" /> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span>$50</span> <span>$300</span> </div> </div> <Separator /> <!-- Color Swatches --> <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"> {#each COLORS as color (color.id)} <button type="button" title={color.label} class={cn( 'focus-visible:ring-ring relative flex items-center justify-center rounded-full border transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none', isMobile ? 'size-8' : 'size-7', color.class, selectedColors.includes(color.id) ? 'ring-primary ring-offset-background ring-2 ring-offset-2' : 'border-border', )} onclick={() => onToggleColor(color.id)} > {#if selectedColors.includes(color.id)} <Check class={cn(isMobile ? 'size-4' : 'size-3.5', color.id === 'white' ? 'text-zinc-950' : 'text-white')} /> {/if} <span class="sr-only">{color.label}</span> </button> {/each} </div> </div> <Separator /> <!-- Brand --> <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"> {#each BRANDS as b (b.id)} <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" > <Checkbox id={`${idPrefix}brand-${b.id}`} checked={selectedBrands.includes(b.id)} onCheckedChange={(v) => onToggleBrand(b.id, v === true)} /> <span>{b.label}</span> </label> <span class="text-muted-foreground text-xs tabular-nums">{getBrandCount(b.id)}</span> </div> {/each} </div> </div> <Separator /> <!-- Rating --> <div class="space-y-3"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase"> {isMobile ? 'Rating' : 'Customer Rating'} </h3> <div class={isMobile ? 'space-y-2' : 'space-y-1.5'}> {#each RATING_OPTIONS as opt (opt.value)} <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', minRating === opt.value ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground', )} onclick={() => onSelectRating(opt.value)} > <div class="flex items-center gap-1.5"> <Star class="fill-warning text-warning size-3.5" /> <span>{opt.label}</span> </div> {#if minRating === opt.value} <Check class="text-primary size-3.5" /> {/if} </button> {/each} </div> </div> </div>src/lib/components/blocks/product-grid-page/product-filter-data.ts1.2 kB
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' }, ]
Raw manifest:https://uipkge.dev/r/svelte/product-grid-page.json