New Components: Field, Input Group, Item and more
Building Blocks for the Web
Clean, modern building blocks. Copy and paste into your apps. Works with all Vue frameworks. Open Source. Free forever.
Files
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Checkbox } from '@/components/ui/checkbox'
import {
Filter,
FilterGroup,
FilterList,
FilterRating,
FilterReset,
FilterPrice,
FilterSwatch,
FilterSwitch,
} from '@/components/ui/filter'
import { ChevronDownIcon, SlidersHorizontalIcon, XIcon } from 'lucide-vue-next'
import ProductCardBasic from '@/components/ProductCardBasic.vue'
// ── Props & Emits ──────────────────────────────────────────────────
const props = withDefaults(
defineProps<{
filters?: UiFilter[]
activeFilters?: Record<string, unknown>
sortOptions?: UiSort[]
activeSorting?: string
totalResults?: number
activeFilterCount?: number
title?: string
description?: string
products?: any[]
}>(),
{
filters: () => mockFilters,
activeFilters: () => ({}),
sortOptions: () => mockSortOptions,
activeSorting: 'default',
totalResults: 156,
activeFilterCount: 0,
title: 'New Arrivals',
description: 'Check out the latest products, fresh from our collection.',
products: () => mockProducts,
}
)
const emit = defineEmits<{
addFilter: [field: string, value: string | boolean | number | { from?: number, to?: number }]
removeFilter: [field: string, value?: string]
filterResult: [field: string, values: string[]]
resetFilter: [field?: string]
sortResult: [sortBy?: string]
resetSorting: []
reset: []
}>()
// ── Internal state ────────────────────────────────────────────────
const mobileSheetOpen = ref(false)
const localFilters = ref<UiFilter[]>(structuredClone(props.filters))
const localSorting = ref(props.activeSorting)
watch(() => props.filters, (v) => { localFilters.value = structuredClone(v) }, { deep: true })
watch(() => props.activeSorting, (v) => { localSorting.value = v })
// ── Active filter value helpers ──────────────────────────────────
function getActiveRating(filterKey: string): number {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && 'from' in val) return Number((val as any).from) || 0
return 0
}
function getActiveBoolean(filterKey: string): boolean {
const val = props.activeFilters?.[filterKey]
if (val === true || val === 'true') return true
if (Array.isArray(val) && val.length > 0) return true
return false
}
function getActiveRange(filterKey: string): { from?: number, to?: number } {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && ('from' in val || 'to' in val)) return val as { from?: number, to?: number }
return {}
}
const localActiveFilterCount = computed(() => {
let count = 0
for (const f of localFilters.value) {
if (f.type === 'rating') {
if (getActiveRating(f.key) > 0) count++
} else if (f.type === 'boolean') {
if (getActiveBoolean(f.key)) count++
} else if (f.type === 'range') {
const range = getActiveRange(f.key)
if (range.from !== undefined || range.to !== undefined) count++
} else {
count += f.options.filter((o) => o.selected).length
}
}
return count
})
const activeBadges = computed(() => {
const badges: { label: string; field: string; value?: string }[] = []
for (const f of localFilters.value) {
if (f.type === 'boolean' || f.type === 'rating') continue
for (const o of f.options) {
if (o.selected) {
badges.push({ label: o.option, field: f.key, value: o.value })
}
}
}
return badges
})
// ── Color hex lookup ─────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
white: '#FFFFFF',
black: '#1A1A1A',
navy: '#1E3A5F',
brown: '#8B4513',
grey: '#808080',
gray: '#808080',
red: '#DC2626',
blue: '#2563EB',
green: '#16A34A',
pink: '#EC4899',
beige: '#D2B48C',
yellow: '#EAB308',
orange: '#EA580C',
purple: '#9333EA',
}
function getSwatchColor(value: string): string {
return COLOR_MAP[value.toLowerCase()] ?? '#94A3B8'
}
// ── Handlers ───────────────────────────────────────────────────────
function toggleFilterOption(filterKey: string, optionValue: string) {
const filter = localFilters.value.find((f) => f.key === filterKey)
if (!filter) return
const option = filter.options.find((o) => o.value === optionValue)
if (!option) return
option.selected = !option.selected
if (option.selected) {
emit('addFilter', filterKey, optionValue)
} else {
emit('removeFilter', filterKey, optionValue)
}
}
function handleToggle(filterKey: string, enabled: boolean) {
if (enabled) {
emit('addFilter', filterKey, true)
} else {
emit('removeFilter', filterKey)
}
}
function handleRating(filterKey: string, value: number) {
if (value > 0) {
emit('addFilter', filterKey, { from: value })
} else {
emit('removeFilter', filterKey)
}
}
function handleRange(filterKey: string, value: { from?: number, to?: number }) {
if (value.from !== undefined || value.to !== undefined) {
emit('addFilter', filterKey, value)
} else {
emit('removeFilter', filterKey)
}
}
function handleSort(value: unknown) {
localSorting.value = String(value)
emit('sortResult', String(value) === 'default' ? undefined : String(value))
}
function removeBadge(field: string, value?: string) {
const filter = localFilters.value.find((f) => f.key === field)
if (!filter) return
const option = filter.options.find((o) => o.value === value)
if (option) option.selected = false
emit('removeFilter', field, value)
}
function resetAll() {
for (const f of localFilters.value) {
for (const o of f.options) {
o.selected = false
}
}
localSorting.value = 'default'
emit('reset')
}
</script>
<script lang="ts">
const _c = 'EUR'
const mockProducts = [
{ name: 'Classic Leather Sneakers', manufacturer: { name: 'Urban Craft' }, price: { amount: 12999, currency: _c, ref: 15999 }, cover: { src: '/placeholder.svg' }, rating: 4.5, reviewCount: 128, badge: 'Sale' },
{ name: 'Running Pro Max', manufacturer: { name: 'SportLine' }, price: { amount: 18999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.8, reviewCount: 64 },
{ name: 'Canvas Low Top', manufacturer: { name: 'StreetWear' }, price: { amount: 5999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.2, reviewCount: 89 },
{ name: 'Suede High Top', manufacturer: { name: 'Urban Craft' }, price: { amount: 14999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.6, reviewCount: 42 },
{ name: 'Minimal White', manufacturer: { name: 'Pure' }, price: { amount: 9999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.4, reviewCount: 201 },
{ name: 'Retro Runner', manufacturer: { name: 'Heritage' }, price: { amount: 11999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.3, reviewCount: 156 },
]
const mockFilters: UiFilter[] = [
{
key: 'properties.color',
label: 'Color',
type: 'color',
options: [
{ option: 'White', value: 'white', count: 24, selected: false, disabled: false },
{ option: 'Black', value: 'black', count: 42, selected: false, disabled: false },
{ option: 'Navy', value: 'navy', count: 18, selected: false, disabled: false },
{ option: 'Brown', value: 'brown', count: 12, selected: false, disabled: false },
{ option: 'Grey', value: 'grey', count: 15, selected: false, disabled: false },
],
},
{
key: 'options.size',
label: 'Size',
type: 'default',
options: [
{ option: 'XS', value: 'xs', count: 8, selected: false, disabled: false },
{ option: 'S', value: 's', count: 15, selected: false, disabled: false },
{ option: 'M', value: 'm', count: 22, selected: false, disabled: false },
{ option: 'L', value: 'l', count: 19, selected: false, disabled: false },
{ option: 'XL', value: 'xl', count: 11, selected: false, disabled: false },
],
},
{
key: 'properties.brand',
label: 'Brand',
type: 'default',
options: [
{ option: 'Urban Craft', value: 'urban-craft', count: 34, selected: false, disabled: false },
{ option: 'SportLine', value: 'sportline', count: 28, selected: false, disabled: false },
{ option: 'Heritage', value: 'heritage', count: 15, selected: false, disabled: false },
],
},
{
key: 'properties.material',
label: 'Material',
type: 'default',
options: [
{ option: 'Leather', value: 'leather', count: 45, selected: false, disabled: false },
{ option: 'Canvas', value: 'canvas', count: 22, selected: false, disabled: false },
{ option: 'Suede', value: 'suede', count: 18, selected: false, disabled: false },
{ option: 'Mesh', value: 'mesh', count: 31, selected: false, disabled: false },
],
},
{
key: 'clearout',
label: 'Clearout',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 12, selected: false, disabled: false },
],
},
{
key: 'rating',
label: 'Minimum Rating',
type: 'rating',
options: [],
},
{ key: 'price', label: 'Price', type: 'range', options: [], data: { min: 0, max: 500, total: 156 } },
]
const mockSortOptions: UiSort[] = [
{ key: 'default', label: 'Relevance', value: 'default' },
{ key: 'price.amount:asc', label: 'Price: Low to High', value: 'price.amount:asc' },
{ key: 'price.amount:desc', label: 'Price: High to Low', value: 'price.amount:desc' },
{ key: 'name:asc', label: 'Name: A–Z', value: 'name:asc' },
]
interface UiFilterOption {
option: string
value: string
count: number
selected: boolean
disabled: boolean
}
interface UiFilter {
key: string
label: string
type: string
options: UiFilterOption[]
data?: { min?: number, max?: number, avg?: number, sum?: number, count?: number, total: number }
}
interface UiSort {
key: string
label: string
value: string
}
</script>
<template>
<div>
<!-- Hero Header -->
<div class="border-b px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl">
<h1 class="text-4xl font-bold tracking-tight">{{ props.title }}</h1>
<p class="mt-4 text-base text-muted-foreground">{{ props.description }}</p>
</div>
</div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Toolbar: Sort + Mobile Filter Toggle -->
<div class="flex items-center justify-between border-b py-4">
<p class="text-sm text-muted-foreground">
{{ props.totalResults }} results
</p>
<div class="flex items-center gap-3">
<!-- Sort (desktop) -->
<div class="hidden items-center gap-2 sm:flex">
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in props.sortOptions"
:key="opt.key"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Mobile: Filter sheet trigger -->
<Sheet v-model:open="mobileSheetOpen">
<SheetTrigger as-child>
<Button variant="outline" size="sm" class="gap-2 lg:hidden">
<SlidersHorizontalIcon class="size-4" />
Filters
<Badge
v-if="localActiveFilterCount > 0"
variant="default"
class="ml-0.5 size-5 rounded-full p-0 text-[10px]"
>
{{ localActiveFilterCount }}
</Badge>
</Button>
</SheetTrigger>
<SheetContent side="right" class="flex w-full flex-col sm:max-w-md">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<ScrollArea class="flex-1">
<Filter :reset="resetAll" class="flex flex-col gap-2 px-4 py-4">
<!-- Sort (mobile only, inside sheet) -->
<div class="sm:hidden">
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-full">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in props.sortOptions" :key="opt.key" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<template v-for="filter in localFilters" :key="filter.key">
<FilterSwitch
v-if="filter.type === 'boolean'"
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
<FilterGroup v-else-if="filter.type === 'rating'" type="collapsible" :label="filter.label">
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else-if="filter.type === 'color'" type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else-if="filter.type === 'range'" type="collapsible" :label="filter.label">
<FilterPrice
currency="EUR"
:precision="0"
:min="filter.data?.min ?? 0"
:max="filter.data?.max ?? 1000"
:step="10"
:value="{ from: getActiveRange(filter.key).from ?? filter.data?.min ?? 0, to: getActiveRange(filter.key).to ?? filter.data?.max ?? 1000 }"
@change="handleRange(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
</FilterGroup>
</template>
<FilterReset />
</Filter>
</ScrollArea>
<SheetFooter class="flex-row gap-2 border-t pt-4">
<Button variant="outline" class="flex-1" :disabled="localActiveFilterCount === 0" @click="resetAll">Reset</Button>
<Button class="flex-1" @click="mobileSheetOpen = false">Show Results ({{ props.totalResults }})</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</div>
<!-- Active Filter Badges -->
<div v-if="activeBadges.length > 0" class="flex flex-wrap items-center gap-2 border-b py-3">
<Badge
v-for="badge in activeBadges"
:key="`${badge.field}-${badge.value}`"
color="secondary"
class="gap-1 pr-1"
>
{{ badge.label }}
<button
class="ml-1 inline-flex size-4 items-center justify-center rounded-full hover:bg-muted-foreground/20"
@click="removeBadge(badge.field, badge.value)"
>
<XIcon class="size-3" />
</button>
</Badge>
<button
class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
@click="resetAll"
>
Clear all
</button>
</div>
<!-- Main: Sidebar + Grid -->
<div class="pt-8 pb-16 lg:grid lg:grid-cols-4 lg:gap-x-8">
<!-- Desktop Sidebar -->
<aside class="hidden lg:block">
<h2 class="sr-only">Filters</h2>
<Filter :reset="resetAll" class="space-y-0 divide-y">
<template v-for="filter in localFilters" :key="filter.key">
<!-- Boolean toggle -->
<div v-if="filter.type === 'boolean'" class="py-6 first:pt-0 last:pb-0">
<FilterSwitch
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
</div>
<!-- Rating -->
<Collapsible v-else-if="filter.type === 'rating'" :default-open="true" class="py-6 first:pt-0 last:pb-0">
<CollapsibleTrigger class="group flex w-full items-center justify-between text-sm font-medium">
{{ filter.label }}
<ChevronDownIcon class="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent class="pt-4">
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</CollapsibleContent>
</Collapsible>
<!-- Color swatch -->
<Collapsible v-else-if="filter.type === 'color'" :default-open="true" class="py-6 first:pt-0 last:pb-0">
<CollapsibleTrigger class="group flex w-full items-center justify-between text-sm font-medium">
<span class="flex items-center gap-2">
{{ filter.label }}
<Badge
v-if="filter.options.filter((o) => o.selected).length"
variant="secondary"
class="size-5 rounded-full p-0 text-[10px]"
>
{{ filter.options.filter((o) => o.selected).length }}
</Badge>
</span>
<ChevronDownIcon class="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent class="pt-4">
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</CollapsibleContent>
</Collapsible>
<!-- Price range -->
<Collapsible v-else-if="filter.type === 'range'" :default-open="true" class="py-6 first:pt-0 last:pb-0">
<CollapsibleTrigger class="group flex w-full items-center justify-between text-sm font-medium">
{{ filter.label }}
<ChevronDownIcon class="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent class="pt-4">
<FilterPrice
currency="EUR"
:precision="0"
:min="filter.data?.min ?? 0"
:max="filter.data?.max ?? 1000"
:step="10"
:value="{ from: getActiveRange(filter.key).from ?? filter.data?.min ?? 0, to: getActiveRange(filter.key).to ?? filter.data?.max ?? 1000 }"
@change="handleRange(filter.key, $event)"
/>
</CollapsibleContent>
</Collapsible>
<!-- Default: checkbox list -->
<Collapsible v-else :default-open="true" class="py-6 first:pt-0 last:pb-0">
<CollapsibleTrigger class="group flex w-full items-center justify-between text-sm font-medium">
<span class="flex items-center gap-2">
{{ filter.label }}
<Badge
v-if="filter.options.filter((o) => o.selected).length"
variant="secondary"
class="size-5 rounded-full p-0 text-[10px]"
>
{{ filter.options.filter((o) => o.selected).length }}
</Badge>
</span>
<ChevronDownIcon class="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent class="pt-4">
<FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
</CollapsibleContent>
</Collapsible>
</template>
</Filter>
</aside>
<!-- Product Grid -->
<div class="mt-6 lg:col-span-3 lg:mt-0">
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
<ProductCardBasic
v-for="(product, index) in props.products"
:key="index"
:product="product"
/>
</div>
<!-- Load More -->
<div class="mt-10 flex justify-center">
<Button variant="outline">Load more</Button>
</div>
</div>
</div>
</div>
</div>
</template>
Sidebar filter layout with collapsible sections, sort dropdown, active filter badges, and product grid.
category-filter
Files
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { SearchIcon } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Filter,
FilterGroup,
FilterList,
FilterRating,
FilterReset,
FilterPrice,
FilterSwatch,
FilterSwitch,
} from '@/components/ui/filter'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import ProductCardBasic from '@/components/ProductCardBasic.vue'
// ── Props & Emits (matching useFronticSearch 1:1) ──────────────────
const props = withDefaults(
defineProps<{
filters?: UiFilter[]
activeFilters?: Record<string, unknown>
sortOptions?: UiSort[]
activeSorting?: string
searchTerm?: string
totalResults?: number
activeFilterCount?: number
category?: string
products?: any[]
}>(),
{
filters: () => mockFilters,
activeFilters: () => ({}),
sortOptions: () => mockSortOptions,
activeSorting: 'default',
searchTerm: '',
totalResults: 156,
activeFilterCount: 0,
category: 'Sneakers',
products: () => mockProducts,
}
)
const emit = defineEmits<{
addFilter: [field: string, value: string | boolean | number | { from?: number, to?: number }]
removeFilter: [field: string, value?: string]
filterResult: [field: string, values: string[]]
resetFilter: [field?: string]
sortResult: [sortBy?: string]
'update:searchTerm': [term: string]
resetSorting: []
reset: []
closeControl: []
}>()
// ── Internal demo state ──────────────────────────────────────────
const isOpen = ref(false)
const localFilters = ref<UiFilter[]>(structuredClone(props.filters))
const localSorting = ref(props.activeSorting)
const localSearch = ref(props.searchTerm)
watch(() => props.filters, (v) => { localFilters.value = structuredClone(v) }, { deep: true })
watch(() => props.activeSorting, (v) => { localSorting.value = v })
watch(() => props.searchTerm, (v) => { localSearch.value = v })
// ── Active filter value helpers ──────────────────────────────────
function getActiveRating(filterKey: string): number {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && 'from' in val) return Number((val as any).from) || 0
return 0
}
function getActiveBoolean(filterKey: string): boolean {
const val = props.activeFilters?.[filterKey]
if (val === true || val === 'true') return true
if (Array.isArray(val) && val.length > 0) return true
return false
}
const localActiveFilterCount = computed(() => {
let count = 0
for (const f of localFilters.value) {
if (f.type === 'rating') {
if (getActiveRating(f.key) > 0) count++
} else if (f.type === 'boolean') {
if (getActiveBoolean(f.key)) count++
} else if (f.type === 'range') {
const range = getActiveRange(f.key)
if (range.from !== undefined || range.to !== undefined) count++
} else {
count += f.options.filter((o) => o.selected).length
}
}
return count
})
const activeBadges = computed(() => {
const badges: { label: string; field: string; value?: string }[] = []
for (const f of localFilters.value) {
if (f.type === 'boolean' || f.type === 'rating') continue
for (const o of f.options) {
if (o.selected) {
badges.push({ label: o.option, field: f.key, value: o.value })
}
}
}
return badges
})
// ── Color hex lookup ─────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
white: '#FFFFFF',
black: '#1A1A1A',
navy: '#1E3A5F',
brown: '#8B4513',
grey: '#808080',
gray: '#808080',
red: '#DC2626',
blue: '#2563EB',
green: '#16A34A',
beige: '#D2B48C',
pink: '#EC4899',
yellow: '#EAB308',
orange: '#EA580C',
purple: '#9333EA',
}
function getSwatchColor(value: string): string {
return COLOR_MAP[value.toLowerCase()] ?? '#94A3B8'
}
// ── Handlers ───────────────────────────────────────────────────────
function toggleFilterOption(filterKey: string, optionValue: string) {
const filter = localFilters.value.find((f) => f.key === filterKey)
if (!filter) return
const option = filter.options.find((o) => o.value === optionValue)
if (!option) return
option.selected = !option.selected
if (option.selected) {
emit('addFilter', filterKey, optionValue)
} else {
emit('removeFilter', filterKey, optionValue)
}
}
function handleToggle(filterKey: string, enabled: boolean) {
if (enabled) {
emit('addFilter', filterKey, true)
} else {
emit('removeFilter', filterKey)
}
}
function handleRating(filterKey: string, value: number) {
if (value > 0) {
emit('addFilter', filterKey, { from: value })
} else {
emit('removeFilter', filterKey)
}
}
function getActiveRange(filterKey: string): { from?: number, to?: number } {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && ('from' in val || 'to' in val)) return val as { from?: number, to?: number }
return {}
}
function handleRange(filterKey: string, value: { from?: number, to?: number }) {
if (value.from !== undefined || value.to !== undefined) {
emit('addFilter', filterKey, value)
} else {
emit('removeFilter', filterKey)
}
}
function handleSort(value: unknown) {
localSorting.value = String(value)
emit('sortResult', String(value) === 'default' ? undefined : String(value))
}
function handleSearch(term: string | number) {
localSearch.value = String(term)
emit('update:searchTerm', String(term))
}
function removeBadge(field: string, value?: string) {
const filter = localFilters.value.find((f) => f.key === field)
if (!filter) return
const option = filter.options.find((o) => o.value === value)
if (option) option.selected = false
emit('removeFilter', field, value)
}
function resetAll() {
for (const f of localFilters.value) {
for (const o of f.options) {
o.selected = false
}
}
localSorting.value = 'default'
localSearch.value = ''
emit('reset')
}
function applyAndClose() {
isOpen.value = false
emit('closeControl')
}
</script>
<script lang="ts">
// ── Static mock data ───────────────────────────────────────────────
const mockFilters: UiFilter[] = [
{
key: 'properties.color',
label: 'Color',
type: 'color',
options: [
{ option: 'White', value: 'white', count: 24, selected: false, disabled: false },
{ option: 'Black', value: 'black', count: 42, selected: false, disabled: false },
{ option: 'Navy', value: 'navy', count: 18, selected: false, disabled: false },
{ option: 'Brown', value: 'brown', count: 12, selected: false, disabled: false },
{ option: 'Grey', value: 'grey', count: 15, selected: false, disabled: false },
],
},
{
key: 'options.size',
label: 'Size',
type: 'default',
options: [
{ option: 'XS', value: 'xs', count: 8, selected: false, disabled: false },
{ option: 'S', value: 's', count: 15, selected: false, disabled: false },
{ option: 'M', value: 'm', count: 22, selected: false, disabled: false },
{ option: 'L', value: 'l', count: 19, selected: false, disabled: false },
{ option: 'XL', value: 'xl', count: 11, selected: false, disabled: false },
],
},
{
key: 'properties.brand',
label: 'Brand',
type: 'default',
options: [
{ option: 'Urban Craft', value: 'urban-craft', count: 34, selected: false, disabled: false },
{ option: 'SportLine', value: 'sportline', count: 28, selected: false, disabled: false },
{ option: 'Heritage', value: 'heritage', count: 15, selected: false, disabled: false },
],
},
{
key: 'properties.material',
label: 'Material',
type: 'default',
options: [
{ option: 'Leather', value: 'leather', count: 45, selected: false, disabled: false },
{ option: 'Canvas', value: 'canvas', count: 22, selected: false, disabled: false },
{ option: 'Suede', value: 'suede', count: 18, selected: false, disabled: false },
{ option: 'Mesh', value: 'mesh', count: 31, selected: false, disabled: false },
],
},
{
key: 'clearout',
label: 'Clearout',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 12, selected: false, disabled: false },
],
},
{
key: 'inStore',
label: 'In Store',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 45, selected: false, disabled: false },
],
},
{
key: 'rating',
label: 'Minimum Rating',
type: 'rating',
options: [],
},
{ key: 'price', label: 'Price Range', type: 'range', options: [], data: { min: 0, max: 500, total: 156 } },
]
const mockSortOptions: UiSort[] = [
{ key: 'default', label: 'Relevance', value: 'default' },
{ key: 'price.amount:asc', label: 'Price: Low to High', value: 'price.amount:asc' },
{ key: 'price.amount:desc', label: 'Price: High to Low', value: 'price.amount:desc' },
{ key: 'name:asc', label: 'Name: A\u2013Z', value: 'name:asc' },
{ key: 'name:desc', label: 'Name: Z\u2013A', value: 'name:desc' },
]
const _c = 'EUR'
const mockProducts = [
{ name: 'Classic Leather Sneakers', manufacturer: { name: 'Urban Craft' }, price: { amount: 12999, currency: _c, ref: 15999 }, cover: { src: '/placeholder.svg' }, rating: 4.5, reviewCount: 128, badge: 'Sale' },
{ name: 'Running Pro Max', manufacturer: { name: 'SportLine' }, price: { amount: 18999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.8, reviewCount: 64 },
{ name: 'Canvas Low Top', manufacturer: { name: 'StreetWear' }, price: { amount: 5999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.2, reviewCount: 89 },
{ name: 'Suede High Top', manufacturer: { name: 'Urban Craft' }, price: { amount: 14999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.6, reviewCount: 42 },
{ name: 'Minimal White', manufacturer: { name: 'Pure' }, price: { amount: 9999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.4, reviewCount: 201 },
{ name: 'Retro Runner', manufacturer: { name: 'Heritage' }, price: { amount: 11999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.3, reviewCount: 156 },
]
interface UiFilterOption {
option: string
value: string
count: number
selected: boolean
disabled: boolean
}
interface UiFilter {
key: string
label: string
type: string
options: UiFilterOption[]
data?: { min?: number, max?: number, avg?: number, sum?: number, count?: number, total: number }
}
interface UiSort {
key: string
label: string
value: string
}
</script>
<template>
<div class="container mx-auto px-4 py-6 md:px-6">
<!-- Header Bar -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-xl font-semibold">{{ props.category }}</h1>
<p class="text-sm text-muted-foreground">{{ props.totalResults }} products</p>
</div>
<div class="flex items-center gap-3">
<!-- Sort Select (desktop) -->
<div class="hidden items-center gap-2 sm:flex">
<span class="text-sm text-muted-foreground">Sort by</span>
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in props.sortOptions"
:key="opt.key"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Filters Button -->
<Sheet v-model:open="isOpen">
<SheetTrigger as-child>
<Button variant="outline" class="gap-2">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
Filters
<Badge
v-if="localActiveFilterCount > 0"
variant="default"
class="ml-1 size-5 rounded-full p-0 text-[10px]"
>
{{ localActiveFilterCount }}
</Badge>
</Button>
</SheetTrigger>
<SheetContent side="right" class="flex w-full flex-col sm:max-w-md">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<ScrollArea class="flex-1">
<Filter :reset="resetAll" class="flex flex-col gap-2 px-4 py-4">
<!-- Search -->
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
:model-value="localSearch"
placeholder="Search products..."
@update:model-value="handleSearch"
/>
</InputGroup>
<!-- Unified filter list — switches on filter.type -->
<template v-for="filter in localFilters" :key="filter.key">
<!-- Boolean toggle -->
<FilterSwitch
v-if="filter.type === 'boolean'"
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
<!-- Rating -->
<FilterGroup
v-else-if="filter.type === 'rating'"
type="collapsible"
:label="filter.label"
>
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</FilterGroup>
<!-- Color swatch -->
<FilterGroup
v-else-if="filter.type === 'color'"
type="collapsible"
:label="filter.label"
:active-count="filter.options.filter((o) => o.selected).length || undefined"
>
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup
v-else-if="filter.type === 'range'"
type="collapsible"
:label="filter.label"
>
<FilterPrice
currency="EUR"
:precision="0"
:min="filter.data?.min ?? 0"
:max="filter.data?.max ?? 1000"
:step="10"
:value="{ from: getActiveRange(filter.key).from ?? filter.data?.min ?? 0, to: getActiveRange(filter.key).to ?? filter.data?.max ?? 1000 }"
@change="handleRange(filter.key, $event)"
/>
</FilterGroup>
<!-- Default: checkbox list -->
<FilterGroup
v-else
type="collapsible"
:label="filter.label"
:active-count="filter.options.filter((o) => o.selected).length || undefined"
>
<FilterList
:options="filter.options"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
</template>
<FilterReset />
</Filter>
</ScrollArea>
<SheetFooter class="flex-row gap-2 border-t pt-4">
<Button class="flex-1" @click="applyAndClose">
Show Results ({{ props.totalResults }})
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</div>
<!-- Active Filter Badges -->
<div v-if="activeBadges.length > 0" class="mb-6 flex flex-wrap items-center gap-2">
<Badge
v-for="badge in activeBadges"
:key="`${badge.field}-${badge.value}`"
color="secondary"
class="gap-1 pr-1"
>
{{ badge.label }}
<button
class="ml-1 inline-flex size-4 items-center justify-center rounded-full hover:bg-muted-foreground/20"
@click="removeBadge(badge.field, badge.value)"
>
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
</Badge>
<button
class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
@click="resetAll"
>
Clear all
</button>
</div>
<!-- Product Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<ProductCardBasic
v-for="(product, index) in props.products"
:key="index"
:product="product"
/>
</div>
<!-- Load More -->
<div class="mt-8 flex justify-center">
<Button variant="outline">Load more</Button>
</div>
</div>
</template>
Sheet/drawer filter panel with sort, search, facets, ranges, toggles, and ratings.
filter-sheet
Files
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { SearchIcon } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Filter,
FilterGroup,
FilterList,
FilterRating,
FilterReset,
FilterPrice,
FilterSwatch,
FilterSwitch,
} from '@/components/ui/filter'
import ProductCardBasic from '@/components/ProductCardBasic.vue'
// ── Props & Emits ──────────────────────────────────────────────────
const props = withDefaults(
defineProps<{
filters?: UiFilter[]
activeFilters?: Record<string, unknown>
sortOptions?: UiSort[]
activeSorting?: string
searchTerm?: string
totalResults?: number
activeFilterCount?: number
category?: string
products?: any[]
}>(),
{
filters: () => mockFilters,
activeFilters: () => ({}),
sortOptions: () => mockSortOptions,
activeSorting: 'default',
searchTerm: '',
totalResults: 156,
activeFilterCount: 0,
category: 'Sneakers',
products: () => mockProducts,
}
)
const emit = defineEmits<{
addFilter: [field: string, value: string | boolean | number | { from?: number, to?: number }]
removeFilter: [field: string, value?: string]
filterResult: [field: string, values: string[]]
resetFilter: [field?: string]
sortResult: [sortBy?: string]
'update:searchTerm': [term: string]
resetSorting: []
reset: []
closeControl: []
}>()
// ── Internal demo state ────────────────────────────────────────────
const sidebarOpen = ref(true)
const mobileSheetOpen = ref(false)
const localFilters = ref<UiFilter[]>(structuredClone(props.filters))
const localSorting = ref(props.activeSorting)
const localSearch = ref(props.searchTerm)
watch(() => props.filters, (v) => { localFilters.value = structuredClone(v) }, { deep: true })
watch(() => props.activeSorting, (v) => { localSorting.value = v })
watch(() => props.searchTerm, (v) => { localSearch.value = v })
// ── Active filter value helpers ──────────────────────────────────
function getActiveRating(filterKey: string): number {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && 'from' in val) return Number((val as any).from) || 0
return 0
}
function getActiveBoolean(filterKey: string): boolean {
const val = props.activeFilters?.[filterKey]
if (val === true || val === 'true') return true
if (Array.isArray(val) && val.length > 0) return true
return false
}
const localActiveFilterCount = computed(() => {
let count = 0
for (const f of localFilters.value) {
if (f.type === 'rating') {
if (getActiveRating(f.key) > 0) count++
} else if (f.type === 'boolean') {
if (getActiveBoolean(f.key)) count++
} else if (f.type === 'range') {
const range = getActiveRange(f.key)
if (range.from !== undefined || range.to !== undefined) count++
} else {
count += f.options.filter((o) => o.selected).length
}
}
return count
})
const activeBadges = computed(() => {
const badges: { label: string; field: string; value?: string }[] = []
for (const f of localFilters.value) {
if (f.type === 'boolean' || f.type === 'rating') continue
for (const o of f.options) {
if (o.selected) {
badges.push({ label: o.option, field: f.key, value: o.value })
}
}
}
return badges
})
// ── Color hex lookup ─────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
white: '#FFFFFF',
black: '#1A1A1A',
navy: '#1E3A5F',
brown: '#8B4513',
grey: '#808080',
gray: '#808080',
red: '#DC2626',
blue: '#2563EB',
green: '#16A34A',
pink: '#EC4899',
beige: '#D2B48C',
yellow: '#EAB308',
orange: '#EA580C',
purple: '#9333EA',
}
function getSwatchColor(value: string): string {
return COLOR_MAP[value.toLowerCase()] ?? '#94A3B8'
}
// ── Handlers ───────────────────────────────────────────────────────
function toggleFilterOption(filterKey: string, optionValue: string) {
const filter = localFilters.value.find((f) => f.key === filterKey)
if (!filter) return
const option = filter.options.find((o) => o.value === optionValue)
if (!option) return
option.selected = !option.selected
if (option.selected) {
emit('addFilter', filterKey, optionValue)
} else {
emit('removeFilter', filterKey, optionValue)
}
}
function handleToggle(filterKey: string, enabled: boolean) {
if (enabled) {
emit('addFilter', filterKey, true)
} else {
emit('removeFilter', filterKey)
}
}
function handleRating(filterKey: string, value: number) {
if (value > 0) {
emit('addFilter', filterKey, { from: value })
} else {
emit('removeFilter', filterKey)
}
}
function getActiveRange(filterKey: string): { from?: number, to?: number } {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && ('from' in val || 'to' in val)) return val as { from?: number, to?: number }
return {}
}
function handleRange(filterKey: string, value: { from?: number, to?: number }) {
if (value.from !== undefined || value.to !== undefined) {
emit('addFilter', filterKey, value)
} else {
emit('removeFilter', filterKey)
}
}
function handleSort(value: unknown) {
localSorting.value = String(value)
emit('sortResult', String(value) === 'default' ? undefined : String(value))
}
function handleSearch(term: string | number) {
localSearch.value = String(term)
emit('update:searchTerm', String(term))
}
function removeBadge(field: string, value?: string) {
const filter = localFilters.value.find((f) => f.key === field)
if (!filter) return
const option = filter.options.find((o) => o.value === value)
if (option) option.selected = false
emit('removeFilter', field, value)
}
function resetAll() {
for (const f of localFilters.value) {
for (const o of f.options) {
o.selected = false
}
}
localSorting.value = 'default'
localSearch.value = ''
emit('reset')
}
</script>
<script lang="ts">
const c = 'EUR'
const mockProducts = [
{ name: 'Classic Leather Sneakers', manufacturer: { name: 'Urban Craft' }, price: { amount: 12999, currency: c, ref: 15999 }, cover: { src: '/placeholder.svg' }, rating: 4.5, reviewCount: 128, badge: 'Sale' },
{ name: 'Running Pro Max', manufacturer: { name: 'SportLine' }, price: { amount: 18999, currency: c }, cover: { src: '/placeholder.svg' }, rating: 4.8, reviewCount: 64 },
{ name: 'Canvas Low Top', manufacturer: { name: 'StreetWear' }, price: { amount: 5999, currency: c }, cover: { src: '/placeholder.svg' }, rating: 4.2, reviewCount: 89 },
{ name: 'Suede High Top', manufacturer: { name: 'Urban Craft' }, price: { amount: 14999, currency: c }, cover: { src: '/placeholder.svg' }, rating: 4.6, reviewCount: 42 },
{ name: 'Minimal White', manufacturer: { name: 'Pure' }, price: { amount: 9999, currency: c }, cover: { src: '/placeholder.svg' }, rating: 4.4, reviewCount: 201 },
{ name: 'Retro Runner', manufacturer: { name: 'Heritage' }, price: { amount: 11999, currency: c }, cover: { src: '/placeholder.svg' }, rating: 4.3, reviewCount: 156 },
]
const mockFilters: UiFilter[] = [
{
key: 'properties.color',
label: 'Color',
type: 'color',
options: [
{ option: 'White', value: 'white', count: 24, selected: false, disabled: false },
{ option: 'Black', value: 'black', count: 42, selected: false, disabled: false },
{ option: 'Navy', value: 'navy', count: 18, selected: false, disabled: false },
{ option: 'Brown', value: 'brown', count: 12, selected: false, disabled: false },
{ option: 'Grey', value: 'grey', count: 15, selected: false, disabled: false },
],
},
{
key: 'options.size',
label: 'Size',
type: 'default',
options: [
{ option: 'XS', value: 'xs', count: 8, selected: false, disabled: false },
{ option: 'S', value: 's', count: 15, selected: false, disabled: false },
{ option: 'M', value: 'm', count: 22, selected: false, disabled: false },
{ option: 'L', value: 'l', count: 19, selected: false, disabled: false },
{ option: 'XL', value: 'xl', count: 11, selected: false, disabled: false },
],
},
{
key: 'properties.brand',
label: 'Brand',
type: 'default',
options: [
{ option: 'Urban Craft', value: 'urban-craft', count: 34, selected: false, disabled: false },
{ option: 'SportLine', value: 'sportline', count: 28, selected: false, disabled: false },
{ option: 'Heritage', value: 'heritage', count: 15, selected: false, disabled: false },
],
},
{
key: 'properties.material',
label: 'Material',
type: 'default',
options: [
{ option: 'Leather', value: 'leather', count: 45, selected: false, disabled: false },
{ option: 'Canvas', value: 'canvas', count: 22, selected: false, disabled: false },
{ option: 'Suede', value: 'suede', count: 18, selected: false, disabled: false },
{ option: 'Mesh', value: 'mesh', count: 31, selected: false, disabled: false },
],
},
{
key: 'clearout',
label: 'Clearout',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 12, selected: false, disabled: false },
],
},
{
key: 'inStore',
label: 'In Store',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 45, selected: false, disabled: false },
],
},
{
key: 'rating',
label: 'Minimum Rating',
type: 'rating',
options: [],
},
{ key: 'price', label: 'Price', type: 'range', options: [], data: { min: 0, max: 500, total: 156 } },
]
const mockSortOptions: UiSort[] = [
{ key: 'default', label: 'Relevance', value: 'default' },
{ key: 'price.amount:asc', label: 'Price: Low to High', value: 'price.amount:asc' },
{ key: 'price.amount:desc', label: 'Price: High to Low', value: 'price.amount:desc' },
{ key: 'name:asc', label: 'Name: A\u2013Z', value: 'name:asc' },
]
interface UiFilterOption {
option: string
value: string
count: number
selected: boolean
disabled: boolean
}
interface UiFilter {
key: string
label: string
type: string
options: UiFilterOption[]
data?: { min?: number, max?: number, avg?: number, sum?: number, count?: number, total: number }
}
interface UiSort {
key: string
label: string
value: string
}
</script>
<template>
<div class="container mx-auto px-4 py-6 md:px-6">
<!-- Header -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-xl font-semibold">{{ props.category }}</h1>
<p class="text-sm text-muted-foreground">{{ props.totalResults }} products</p>
</div>
<div class="flex items-center gap-3">
<!-- Desktop: Toggle sidebar button -->
<Button
variant="outline"
class="hidden gap-2 md:inline-flex"
@click="sidebarOpen = !sidebarOpen"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /></svg>
{{ sidebarOpen ? 'Hide Filters' : 'Show Filters' }}
<Badge
v-if="localActiveFilterCount > 0"
variant="default"
class="ml-1 size-5 rounded-full p-0 text-[10px]"
>
{{ localActiveFilterCount }}
</Badge>
</Button>
<!-- Mobile: Filter sheet button -->
<Sheet v-model:open="mobileSheetOpen">
<SheetTrigger as-child>
<Button variant="outline" class="gap-2 md:hidden">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
Filters
<Badge
v-if="localActiveFilterCount > 0"
variant="default"
class="ml-1 size-5 rounded-full p-0 text-[10px]"
>
{{ localActiveFilterCount }}
</Badge>
</Button>
</SheetTrigger>
<SheetContent side="bottom" class="flex max-h-[85vh] flex-col">
<SheetHeader>
<SheetTitle>Filters & Sort</SheetTitle>
</SheetHeader>
<ScrollArea class="flex-1">
<Filter :reset="resetAll" class="flex flex-col gap-2 px-4 py-4">
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-full">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in props.sortOptions" :key="opt.key" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
:model-value="localSearch"
placeholder="Search products..."
@update:model-value="handleSearch"
/>
</InputGroup>
<template v-for="filter in localFilters" :key="filter.key">
<FilterSwitch
v-if="filter.type === 'boolean'"
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
<FilterGroup v-else-if="filter.type === 'rating'" type="collapsible" :label="filter.label">
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else-if="filter.type === 'color'" type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else-if="filter.type === 'range'" type="collapsible" :label="filter.label">
<FilterPrice
currency="EUR"
:precision="0"
:min="filter.data?.min ?? 0"
:max="filter.data?.max ?? 1000"
:step="10"
:value="{ from: getActiveRange(filter.key).from ?? filter.data?.min ?? 0, to: getActiveRange(filter.key).to ?? filter.data?.max ?? 1000 }"
@change="handleRange(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
</FilterGroup>
</template>
<FilterReset />
</Filter>
</ScrollArea>
<SheetFooter class="flex-row gap-2 border-t pt-4">
<Button variant="outline" class="flex-1" :disabled="localActiveFilterCount === 0" @click="resetAll">Reset</Button>
<Button class="flex-1" @click="mobileSheetOpen = false">Show Results ({{ props.totalResults }})</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<!-- Sort (desktop) -->
<div class="hidden items-center gap-2 sm:flex">
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in props.sortOptions" :key="opt.key" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<!-- Active Filter Badges -->
<div v-if="activeBadges.length > 0" class="mb-4 flex flex-wrap items-center gap-2">
<Badge
v-for="badge in activeBadges"
:key="`${badge.field}-${badge.value}`"
color="secondary"
class="gap-1 pr-1"
>
{{ badge.label }}
<button class="ml-1 inline-flex size-4 items-center justify-center rounded-full hover:bg-muted-foreground/20" @click="removeBadge(badge.field, badge.value)">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
</Badge>
<button class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" @click="resetAll">Clear all</button>
</div>
<!-- Main Content: Sidebar + Grid -->
<div class="flex gap-6">
<!-- Inline Sidebar (desktop only) -->
<aside
class="hidden shrink-0 overflow-hidden transition-all duration-300 md:block"
:class="sidebarOpen ? 'w-64' : 'w-0'"
>
<div class="w-64">
<Filter :reset="resetAll" class="flex flex-col gap-2">
<!-- Search -->
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
:model-value="localSearch"
placeholder="Search products..."
@update:model-value="handleSearch"
/>
</InputGroup>
<!-- Unified filter list -->
<template v-for="filter in localFilters" :key="filter.key">
<FilterSwitch
v-if="filter.type === 'boolean'"
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
<FilterGroup v-else-if="filter.type === 'rating'" type="collapsible" :label="filter.label">
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup v-else-if="filter.type === 'color'" type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
<template v-else-if="filter.type === 'range'" />
<FilterGroup v-else type="collapsible" :label="filter.label" :active-count="filter.options.filter((o) => o.selected).length || undefined">
<FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
</FilterGroup>
</template>
<FilterReset />
</Filter>
</div>
</aside>
<!-- Product Grid -->
<div class="flex-1">
<div
class="grid grid-cols-2 gap-4 transition-all duration-300"
:class="sidebarOpen ? 'lg:grid-cols-3' : 'lg:grid-cols-4'"
>
<ProductCardBasic
v-for="(product, index) in props.products"
:key="index"
:product="product"
/>
</div>
<!-- Load More -->
<div class="mt-8 flex justify-center">
<Button variant="outline">Load more</Button>
</div>
</div>
</div>
</div>
</template>
Inline expandable sidebar filter panel alongside a product grid.
filter-sidebar
Files
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Filter,
FilterGroup,
FilterList,
FilterRating,
FilterReset,
FilterPrice,
FilterSwatch,
FilterSwitch,
} from '@/components/ui/filter'
import ProductCardBasic from '@/components/ProductCardBasic.vue'
// ── Props & Emits ──────────────────────────────────────────────────
const props = withDefaults(
defineProps<{
filters?: UiFilter[]
activeFilters?: Record<string, unknown>
sortOptions?: UiSort[]
activeSorting?: string
searchTerm?: string
totalResults?: number
activeFilterCount?: number
category?: string
products?: any[]
}>(),
{
filters: () => mockFilters,
activeFilters: () => ({}),
sortOptions: () => mockSortOptions,
activeSorting: 'default',
searchTerm: '',
totalResults: 156,
activeFilterCount: 0,
category: 'Sneakers',
products: () => mockProducts,
}
)
const emit = defineEmits<{
addFilter: [field: string, value: string | boolean | number | { from?: number, to?: number }]
removeFilter: [field: string, value?: string]
filterResult: [field: string, values: string[]]
resetFilter: [field?: string]
sortResult: [sortBy?: string]
'update:searchTerm': [term: string]
resetSorting: []
reset: []
closeControl: []
}>()
// ── Internal demo state ────────────────────────────────────────────
const mobileSheetOpen = ref(false)
const localFilters = ref<UiFilter[]>(structuredClone(props.filters))
const localSorting = ref(props.activeSorting)
const localSearch = ref(props.searchTerm)
watch(() => props.filters, (v) => { localFilters.value = structuredClone(v) }, { deep: true })
watch(() => props.activeSorting, (v) => { localSorting.value = v })
watch(() => props.searchTerm, (v) => { localSearch.value = v })
// Split filters by type for desktop layout
const popoverFilters = computed(() => localFilters.value.filter((f) => f.type !== 'boolean' && f.type !== 'rating' && f.type !== 'range'))
const toggleFilters = computed(() => localFilters.value.filter((f) => f.type === 'boolean'))
const ratingFilters = computed(() => localFilters.value.filter((f) => f.type === 'rating'))
// ── Active filter value helpers ──────────────────────────────────
function getActiveRating(filterKey: string): number {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && 'from' in val) return Number((val as any).from) || 0
return 0
}
function getActiveBoolean(filterKey: string): boolean {
const val = props.activeFilters?.[filterKey]
if (val === true || val === 'true') return true
if (Array.isArray(val) && val.length > 0) return true
return false
}
const localActiveFilterCount = computed(() => {
let count = 0
for (const f of localFilters.value) {
if (f.type === 'rating') {
if (getActiveRating(f.key) > 0) count++
} else if (f.type === 'boolean') {
if (getActiveBoolean(f.key)) count++
} else if (f.type === 'range') {
const range = getActiveRange(f.key)
if (range.from !== undefined || range.to !== undefined) count++
} else {
count += f.options.filter((o) => o.selected).length
}
}
return count
})
const activeBadges = computed(() => {
const badges: { label: string; field: string; value?: string }[] = []
for (const f of localFilters.value) {
if (f.type === 'boolean' || f.type === 'rating') continue
for (const o of f.options) {
if (o.selected) {
badges.push({ label: o.option, field: f.key, value: o.value })
}
}
}
return badges
})
// ── Color hex lookup ─────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
white: '#FFFFFF',
black: '#1A1A1A',
navy: '#1E3A5F',
brown: '#8B4513',
grey: '#808080',
gray: '#808080',
red: '#DC2626',
blue: '#2563EB',
green: '#16A34A',
beige: '#D2B48C',
pink: '#EC4899',
yellow: '#EAB308',
orange: '#EA580C',
purple: '#9333EA',
}
function getSwatchColor(value: string): string {
return COLOR_MAP[value.toLowerCase()] ?? '#94A3B8'
}
// ── Handlers ───────────────────────────────────────────────────────
function toggleFilterOption(filterKey: string, optionValue: string) {
const filter = localFilters.value.find((f) => f.key === filterKey)
if (!filter) return
const option = filter.options.find((o) => o.value === optionValue)
if (!option) return
option.selected = !option.selected
if (option.selected) {
emit('addFilter', filterKey, optionValue)
} else {
emit('removeFilter', filterKey, optionValue)
}
}
function handleToggle(filterKey: string, enabled: boolean) {
if (enabled) {
emit('addFilter', filterKey, true)
} else {
emit('removeFilter', filterKey)
}
}
function handleRating(filterKey: string, value: number) {
if (value > 0) {
emit('addFilter', filterKey, { from: value })
} else {
emit('removeFilter', filterKey)
}
}
function getActiveRange(filterKey: string): { from?: number, to?: number } {
const val = props.activeFilters?.[filterKey]
if (val && typeof val === 'object' && ('from' in val || 'to' in val)) return val as { from?: number, to?: number }
return {}
}
function handleRange(filterKey: string, value: { from?: number, to?: number }) {
if (value.from !== undefined || value.to !== undefined) {
emit('addFilter', filterKey, value)
} else {
emit('removeFilter', filterKey)
}
}
function handleSort(value: unknown) {
localSorting.value = String(value)
emit('sortResult', String(value) === 'default' ? undefined : String(value))
}
function handleSearch(term: string | number) {
localSearch.value = String(term)
emit('update:searchTerm', String(term))
}
function removeBadge(field: string, value?: string) {
const filter = localFilters.value.find((f) => f.key === field)
if (!filter) return
const option = filter.options.find((o) => o.value === value)
if (option) option.selected = false
emit('removeFilter', field, value)
}
function resetAll() {
for (const f of localFilters.value) {
for (const o of f.options) {
o.selected = false
}
}
localSorting.value = 'default'
localSearch.value = ''
emit('reset')
}
</script>
<script lang="ts">
const _c = 'EUR'
const mockProducts = [
{ name: 'Classic Leather Sneakers', manufacturer: { name: 'Urban Craft' }, price: { amount: 12999, currency: _c, ref: 15999 }, cover: { src: '/placeholder.svg' }, rating: 4.5, reviewCount: 128, badge: 'Sale' },
{ name: 'Running Pro Max', manufacturer: { name: 'SportLine' }, price: { amount: 18999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.8, reviewCount: 64 },
{ name: 'Canvas Low Top', manufacturer: { name: 'StreetWear' }, price: { amount: 5999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.2, reviewCount: 89 },
{ name: 'Suede High Top', manufacturer: { name: 'Urban Craft' }, price: { amount: 14999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.6, reviewCount: 42 },
{ name: 'Minimal White', manufacturer: { name: 'Pure' }, price: { amount: 9999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.4, reviewCount: 201 },
{ name: 'Retro Runner', manufacturer: { name: 'Heritage' }, price: { amount: 11999, currency: _c }, cover: { src: '/placeholder.svg' }, rating: 4.3, reviewCount: 156 },
]
const mockFilters: UiFilter[] = [
{
key: 'properties.color',
label: 'Color',
type: 'color',
options: [
{ option: 'White', value: 'white', count: 24, selected: false, disabled: false },
{ option: 'Black', value: 'black', count: 42, selected: false, disabled: false },
{ option: 'Navy', value: 'navy', count: 18, selected: false, disabled: false },
{ option: 'Brown', value: 'brown', count: 12, selected: false, disabled: false },
{ option: 'Grey', value: 'grey', count: 15, selected: false, disabled: false },
],
},
{
key: 'options.size',
label: 'Size',
type: 'default',
options: [
{ option: 'XS', value: 'xs', count: 8, selected: false, disabled: false },
{ option: 'S', value: 's', count: 15, selected: false, disabled: false },
{ option: 'M', value: 'm', count: 22, selected: false, disabled: false },
{ option: 'L', value: 'l', count: 19, selected: false, disabled: false },
{ option: 'XL', value: 'xl', count: 11, selected: false, disabled: false },
],
},
{
key: 'properties.brand',
label: 'Brand',
type: 'default',
options: [
{ option: 'Urban Craft', value: 'urban-craft', count: 34, selected: false, disabled: false },
{ option: 'SportLine', value: 'sportline', count: 28, selected: false, disabled: false },
{ option: 'Heritage', value: 'heritage', count: 15, selected: false, disabled: false },
],
},
{
key: 'clearout',
label: 'Clearout',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 12, selected: false, disabled: false },
],
},
{
key: 'inStore',
label: 'In Store',
type: 'boolean',
options: [
{ option: 'Yes', value: 'true', count: 45, selected: false, disabled: false },
],
},
{
key: 'rating',
label: 'Minimum Rating',
type: 'rating',
options: [],
},
{ key: 'price', label: 'Max Price', type: 'range', options: [], data: { min: 0, max: 500, total: 156 } },
]
const mockSortOptions: UiSort[] = [
{ key: 'default', label: 'Relevance', value: 'default' },
{ key: 'price.amount:asc', label: 'Price: Low to High', value: 'price.amount:asc' },
{ key: 'price.amount:desc', label: 'Price: High to Low', value: 'price.amount:desc' },
{ key: 'name:asc', label: 'Name: A\u2013Z', value: 'name:asc' },
]
interface UiFilterOption {
option: string
value: string
count: number
selected: boolean
disabled: boolean
}
interface UiFilter {
key: string
label: string
type: string
options: UiFilterOption[]
data?: { min?: number, max?: number, avg?: number, sum?: number, count?: number, total: number }
}
interface UiSort {
key: string
label: string
value: string
}
</script>
<template>
<div class="container mx-auto px-4 py-6 md:px-6">
<!-- Category Title -->
<div class="mb-4">
<h1 class="text-xl font-semibold">{{ props.category }}</h1>
<p class="text-sm text-muted-foreground">{{ props.totalResults }} products</p>
</div>
<!-- Desktop Toolbar -->
<div class="mb-4 hidden items-center gap-3 rounded-lg border bg-card p-3 md:flex">
<!-- Search -->
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<Input
:model-value="localSearch"
placeholder="Search products..."
class="pl-10"
@update:model-value="handleSearch"
/>
</div>
<Separator orientation="vertical" class="h-8" />
<!-- Filter Dropdowns (facets + color only) -->
<template v-for="filter in popoverFilters" :key="filter.key">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
{{ filter.label }}
<Badge
v-if="filter.options.some((o) => o.selected)"
variant="default"
class="ml-0.5 size-5 rounded-full p-0 text-[10px]"
>
{{ filter.options.filter((o) => o.selected).length }}
</Badge>
<svg class="size-3 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</Button>
</PopoverTrigger>
<PopoverContent class="w-56 p-2" align="start">
<FilterSwatch
v-if="filter.type === 'color'"
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
class="p-1"
@toggle="toggleFilterOption(filter.key, $event)"
/>
<FilterList
v-else
:options="filter.options"
class="p-1"
@toggle="toggleFilterOption(filter.key, $event)"
/>
<Separator v-if="filter.options.some((o) => o.selected)" class="my-1" />
<Button
v-if="filter.options.some((o) => o.selected)"
variant="ghost"
size="sm"
class="w-full text-xs"
@click="
filter.options.forEach((o) => (o.selected = false));
emit('resetFilter', filter.key)
"
>
Clear {{ filter.label }}
</Button>
</PopoverContent>
</Popover>
</template>
<Separator orientation="vertical" class="h-8" />
<!-- Sort -->
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in props.sortOptions"
:key="opt.key"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Below toolbar: Toggles, Rating (desktop) -->
<div
v-if="toggleFilters.length > 0 || ratingFilters.length > 0"
class="mb-4 hidden items-center gap-6 md:flex"
>
<!-- Boolean toggles inline -->
<FilterSwitch
v-for="toggle in toggleFilters"
:key="toggle.key"
:value="getActiveBoolean(toggle.key)"
:label="toggle.label"
@change="handleToggle(toggle.key, $event)"
/>
<Separator v-if="toggleFilters.length > 0 && ratingFilters.length > 0" orientation="vertical" class="h-8" />
<!-- Rating inline -->
<FilterRating
v-for="rating in ratingFilters"
:key="rating.key"
:value="getActiveRating(rating.key)"
:label="rating.label"
:max="5"
@change="handleRating(rating.key, $event)"
/>
</div>
<!-- Mobile: Search + Filters button -->
<div class="mb-4 flex items-center gap-2 md:hidden">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
<Input
:model-value="localSearch"
placeholder="Search..."
class="pl-10"
@update:model-value="handleSearch"
/>
</div>
<Sheet v-model:open="mobileSheetOpen">
<SheetTrigger as-child>
<Button variant="outline" class="gap-2">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
Filters
<Badge
v-if="localActiveFilterCount > 0"
variant="default"
class="ml-1 size-5 rounded-full p-0 text-[10px]"
>
{{ localActiveFilterCount }}
</Badge>
</Button>
</SheetTrigger>
<SheetContent side="bottom" class="flex max-h-[85vh] flex-col">
<SheetHeader>
<SheetTitle>Filters & Sort</SheetTitle>
</SheetHeader>
<ScrollArea class="flex-1 -mx-6 px-6">
<Filter :reset="resetAll" class="space-y-4 pb-6">
<!-- Sort -->
<Select :model-value="localSorting" @update:model-value="handleSort">
<SelectTrigger class="w-full">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in props.sortOptions"
:key="opt.key"
:value="opt.value"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Separator />
<!-- Unified filter list — switches on filter.type -->
<template v-for="filter in localFilters" :key="filter.key">
<!-- Boolean toggle -->
<FilterSwitch
v-if="filter.type === 'boolean'"
:value="getActiveBoolean(filter.key)"
:label="filter.label"
@change="handleToggle(filter.key, $event)"
/>
<!-- Rating -->
<FilterGroup
v-else-if="filter.type === 'rating'"
type="collapsible"
:label="filter.label"
>
<FilterRating
:value="getActiveRating(filter.key)"
:max="5"
@change="handleRating(filter.key, $event)"
/>
</FilterGroup>
<!-- Color swatch -->
<FilterGroup
v-else-if="filter.type === 'color'"
type="collapsible"
:label="filter.label"
:active-count="filter.options.filter((o) => o.selected).length || undefined"
>
<FilterSwatch
:options="filter.options.map(o => ({ ...o, hex: getSwatchColor(o.value), label: o.option }))"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
<FilterGroup
v-else-if="filter.type === 'range'"
type="collapsible"
:label="filter.label"
>
<FilterPrice
currency="EUR"
:precision="0"
:min="filter.data?.min ?? 0"
:max="filter.data?.max ?? 1000"
:step="10"
:value="{ to: getActiveRange(filter.key).to ?? filter.data?.max ?? 1000 }"
@change="handleRange(filter.key, $event)"
/>
</FilterGroup>
<!-- Default: checkbox list -->
<FilterGroup
v-else
type="collapsible"
:label="filter.label"
:active-count="filter.options.filter((o) => o.selected).length || undefined"
>
<FilterList
:options="filter.options"
@toggle="toggleFilterOption(filter.key, $event)"
/>
</FilterGroup>
</template>
<FilterReset />
</Filter>
</ScrollArea>
<SheetFooter class="flex-row gap-2 border-t pt-4">
<Button variant="outline" class="flex-1" :disabled="localActiveFilterCount === 0" @click="resetAll">
Reset
</Button>
<Button class="flex-1" @click="mobileSheetOpen = false">
Show Results ({{ props.totalResults }})
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
<!-- Active Filter Badges -->
<div v-if="activeBadges.length > 0" class="mb-4 flex flex-wrap items-center gap-2">
<Badge
v-for="badge in activeBadges"
:key="`${badge.field}-${badge.value}`"
color="secondary"
class="gap-1 pr-1"
>
{{ badge.label }}
<button
class="ml-1 inline-flex size-4 items-center justify-center rounded-full hover:bg-muted-foreground/20"
@click="removeBadge(badge.field, badge.value)"
>
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
</Badge>
<button
class="text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
@click="resetAll"
>
Clear all
</button>
</div>
<!-- Product Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<ProductCardBasic
v-for="(product, index) in props.products"
:key="index"
:product="product"
/>
</div>
<!-- Load More -->
<div class="mt-8 flex justify-center">
<Button variant="outline">Load more</Button>
</div>
</div>
</template>
Horizontal toolbar filter panel with dropdown facets above a product grid.
filter-toolbar