10.1k
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
components/CategoryFilter.vue
<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
components/FilterSheet.vue
<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
components/FilterSidebar.vue
<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
components/FilterToolbar.vue
<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