---
name: filter-toolbar
title: Filter Toolbar
url: https://ui.frontic.com/components/templates/filter-toolbar.md
tier: templates
install: npx @frontic/ui add filter-toolbar
files:
  - components/templates/filter-toolbar/FilterToolbar.vue
  - components/templates/filter-toolbar/index.ts
  - components/templates/filter-toolbar/types.ts
registryDependencies:
  - badge
  - button
  - empty
  - filter
  - filter-model
  - input
  - popover
  - product-card-basic
  - scroll-area
  - select
  - separator
  - sheet
  - tag-group
  - use-filter-state
dependencies:
  - "@lucide/vue"
---

# Filter Toolbar

A product list with its filters as popovers in a toolbar above it, plus sorting and search.

## Install

```sh
npx @frontic/ui add filter-toolbar
```

Built from: [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Empty](https://ui.frontic.com/components/ui/empty.md), [Filter](https://ui.frontic.com/components/ui/filter.md), [Filter Model](https://ui.frontic.com/lib/filter-model.md), [Input](https://ui.frontic.com/components/ui/input.md), [Popover](https://ui.frontic.com/components/ui/popover.md), [Product Card Basic](https://ui.frontic.com/components/sections/product-card-basic.md), [Scroll Area](https://ui.frontic.com/components/ui/scroll-area.md), [Select](https://ui.frontic.com/components/ui/select.md), [Separator](https://ui.frontic.com/components/ui/separator.md), [Sheet](https://ui.frontic.com/components/ui/sheet.md), [Tag Group](https://ui.frontic.com/components/ui/tag-group.md), [Use Filter State](https://ui.frontic.com/composables/use-filter-state.md)

npm packages: `@lucide/vue`

## Examples

```vue
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { FilterToolbar, type Product, type UiFilter, type UiFilterValue, type UiSort } from '@/components/templates/filter-toolbar'

const image = (name: string) => `https://static.frontic.com/cdn-cgi/image/width=640,format=auto/ui/products/${name}.jpg`

// The demo shop's men's jackets: catalog names, prices, colours and images. Each colour of a product is a variant.
const JACKETS = [
  {
    name: 'Summit Series Winter Parka',
    price: { amount: 13200, currency: 'EUR', ref: 15900 },
    rating: 4.7,
    reviewCount: 212,
    variants: [{ slug: 'summit-series-winter-parka-57dec9f4', colour: 'Charcoal', hex: '#4a4a47', image: 'summit-series-winter-parka' }],
  },
  {
    name: 'Glacier Explorer',
    price: { amount: 9400, currency: 'EUR' },
    rating: 4.4,
    reviewCount: 96,
    variants: [{ slug: 'glacier-explorer-53fb49de', colour: 'Slate blue', hex: '#5f7680', image: 'glacier-explorer' }],
  },
  {
    name: 'Winter Cliffs Hiking Jacket',
    price: { amount: 5200, currency: 'EUR' },
    rating: 4.1,
    reviewCount: 58,
    variants: [{ slug: 'winter-cliffs-hiking-jacket-4d851b8e', colour: 'Ochre', hex: '#c08a3c', image: 'winter-cliffs-hiking-jacket' }],
  },
  {
    name: 'Frost Cruiser Softshell',
    price: { amount: 10700, currency: 'EUR' },
    rating: 4.6,
    reviewCount: 143,
    variants: [{ slug: 'frost-cruiser-softshell-4d57b624', colour: 'Steel grey', hex: '#8e98a4', image: 'frost-cruiser-softshell' }],
  },
  {
    name: 'Glacier Hiker Coat',
    price: { amount: 8400, currency: 'EUR', ref: 10500 },
    rating: 4.3,
    reviewCount: 77,
    variants: [{ slug: 'glacierhiker-coat-4cbce0c8', colour: 'Dark olive', hex: '#55573f', image: 'glacier-hiker-coat' }],
  },
  {
    name: 'Summit Strider',
    price: { amount: 8100, currency: 'EUR' },
    rating: 4.0,
    reviewCount: 41,
    variants: [{ slug: 'summit-strider-4a291024', colour: 'Saffron', hex: '#e5a63c', image: 'summit-strider' }],
  },
  {
    name: 'Polar Light Ski Jacket',
    price: { amount: 7900, currency: 'EUR' },
    rating: 4.8,
    reviewCount: 187,
    variants: [{ slug: 'polarlicht-ski-jacket-476160a6', colour: 'Dusty pink', hex: '#d6aaa6', image: 'polar-light-ski-jacket' }],
  },
  {
    name: 'Glacier Fever Functional Jacket',
    price: { amount: 6500, currency: 'EUR' },
    rating: 4.2,
    reviewCount: 63,
    variants: [{ slug: 'glacier-fever-functional-jacket-476c5729', colour: 'Mustard', hex: '#cf9e42', image: 'glacier-fever-functional-jacket' }],
  },
  {
    name: 'Arctic Fox Winter Jacket',
    price: { amount: 6100, currency: 'EUR', ref: 7900 },
    rating: 4.5,
    reviewCount: 119,
    variants: [{ slug: 'polarfuchs-winter-jacket-47b6cbf5', colour: 'Light grey', hex: '#d0d0ce', image: 'arctic-fox-winter-jacket' }],
  },
  {
    name: 'Arctic Mission Jacket',
    price: { amount: 8700, currency: 'EUR' },
    rating: 4.4,
    reviewCount: 88,
    variants: [{ slug: 'arktische-mission-jacket-45628700', colour: 'Dark brown', hex: '#4d3e34', image: 'arctic-mission-jacket' }],
  },
  {
    name: 'Blizzard Defender',
    price: { amount: 12600, currency: 'EUR' },
    rating: 4.9,
    reviewCount: 264,
    variants: [
      { slug: 'blizzard-defender-455fb5a3', colour: 'Aubergine', hex: '#3d3347', image: 'blizzard-defender-aubergine' },
      { slug: 'blizzard-defender-41d5aa85', colour: 'Dusty rose', hex: '#b98a87', image: 'blizzard-defender-dusty-rose' },
      { slug: 'blizzard-defender-393a11b2', colour: 'Salmon', hex: '#dc967b', image: 'blizzard-defender-salmon' },
    ],
  },
  {
    name: 'High Altitude Trekking Jacket',
    price: { amount: 6200, currency: 'EUR' },
    rating: 3.9,
    reviewCount: 35,
    variants: [{ slug: 'hoehenrausch-trekking-jacket-3fc839ea', colour: 'Olive', hex: '#a09d5c', image: 'high-altitude-trekking-jacket' }],
  },
  {
    name: 'Arctic Fox Hybrid Coat',
    price: { amount: 6800, currency: 'EUR' },
    rating: 4.3,
    reviewCount: 72,
    variants: [{ slug: 'arctic-fox-hybrid-coat-3f543a88', colour: 'Terracotta', hex: '#c4735f', image: 'arctic-fox-hybrid-coat' }],
  },
  {
    name: 'Summit Stormer Parka',
    price: { amount: 5700, currency: 'EUR' },
    rating: 4.1,
    reviewCount: 49,
    variants: [{ slug: 'gipfelstuermer-parka-3dcb8f45', colour: 'Amber', hex: '#e2a240', image: 'summit-stormer-parka' }],
  },
  {
    name: 'Mountaineer Hybrid Jacket',
    price: { amount: 9100, currency: 'EUR' },
    rating: 4.6,
    reviewCount: 131,
    variants: [{ slug: 'mountaineer-hybrid-jacket-3d91f9e2', colour: 'Charcoal', hex: '#4a4a47', image: 'mountaineer-hybrid-jacket' }],
  },
  {
    name: 'Aurora Functional Jacket',
    price: { amount: 7400, currency: 'EUR' },
    rating: 4.2,
    reviewCount: 54,
    variants: [{ slug: 'nordlichter-functional-jacket-3b5b93cb', colour: 'Rust', hex: '#b0593f', image: 'aurora-functional-jacket' }],
  },
  {
    name: 'Frost Escape Parka',
    price: { amount: 14900, currency: 'EUR' },
    rating: 4.8,
    reviewCount: 236,
    variants: [{ slug: 'frost-escape-parka-3abd4b58', colour: 'Sun yellow', hex: '#e5ad40', image: 'frost-escape-parka' }],
  },
  {
    name: 'Alpine Trekker',
    price: { amount: 12600, currency: 'EUR' },
    rating: 4.7,
    reviewCount: 158,
    variants: [{ slug: 'alpine-trekker-37813563', colour: 'Stone grey', hex: '#9c9c9a', image: 'alpine-trekker' }],
  },
  {
    name: 'Arctic Mission Parka',
    price: { amount: 11980, currency: 'EUR' },
    rating: 4.5,
    reviewCount: 102,
    variants: [{ slug: 'arktische-mission-jacket-397a17fd', colour: 'Burnt orange', hex: '#c8704f', image: 'arctic-mission-parka' }],
  },
]
type Jacket = (typeof JACKETS)[number]

// Every men's jacket comes in all five sizes.
const SIZES = ['S', 'M', 'L', 'XL', 'XXL']
const VARIANTS = JACKETS.flatMap((jacket) => jacket.variants)
const COLOURS = [...new Set(VARIANTS.map((variant) => variant.colour))].sort()
const swatchColors = Object.fromEntries(VARIANTS.map((variant) => [variant.colour.toLowerCase(), variant.hex]))

const PAGE = 12
const euros = JACKETS.map((jacket) => jacket.price.amount / 100)
const PRICE = { min: Math.floor(Math.min(...euros) / 10) * 10, max: Math.ceil(Math.max(...euros) / 10) * 10 }

const state = reactive({
  colours: new Set<string>(),
  sizes: new Set<string>(['M']),
  price: {} as { from?: number; to?: number },
  search: '',
  sort: 'default',
})
const shown = ref(PAGE)

const matches = (jacket: Jacket, skip?: 'colour') =>
  (skip === 'colour' || !state.colours.size || jacket.variants.some((variant) => state.colours.has(variant.colour))) &&
  jacket.price.amount / 100 >= (state.price.from ?? PRICE.min) &&
  jacket.price.amount / 100 <= (state.price.to ?? PRICE.max) &&
  jacket.name.toLowerCase().includes(state.search.trim().toLowerCase())

const SORTS: Record<string, (a: Jacket, b: Jacket) => number> = {
  'price.amount:asc': (a, b) => a.price.amount - b.price.amount,
  'price.amount:desc': (a, b) => b.price.amount - a.price.amount,
  'name:asc': (a, b) => a.name.localeCompare(b.name),
}

const results = computed(() => {
  const found = JACKETS.filter((jacket) => matches(jacket))
  return SORTS[state.sort] ? [...found].sort(SORTS[state.sort]) : found
})

const filters = computed<UiFilter[]>(() => [
  {
    key: 'colour',
    label: 'Colour',
    type: 'color',
    options: COLOURS.map((colour) => {
      const count = JACKETS.filter((jacket) => matches(jacket, 'colour') && jacket.variants.some((variant) => variant.colour === colour)).length
      const selected = state.colours.has(colour)
      return { option: colour, value: colour, count, selected, disabled: !count && !selected }
    }),
  },
  {
    key: 'size',
    label: 'Size',
    type: 'default',
    options: SIZES.map((size) => ({ option: size, value: size, count: results.value.length, selected: state.sizes.has(size), disabled: !results.value.length && !state.sizes.has(size) })),
  },
  { key: 'price', label: 'Max price', type: 'range', options: [], data: { total: { ...PRICE, total: JACKETS.length }, result: { ...PRICE, total: results.value.length } } },
])

const activeFilters = computed(() => (state.price.from !== undefined || state.price.to !== undefined ? { price: state.price } : {}))

const sortOptions: 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' },
]

const products = computed<Product[]>(() =>
  results.value.slice(0, shown.value).map((jacket) => {
    const variant = jacket.variants.find((v) => state.colours.has(v.colour)) ?? jacket.variants[0]!
    return {
      key: variant.slug,
      name: jacket.name,
      price: jacket.price,
      cover: { src: image(variant.image) },
      rating: jacket.rating,
      reviewCount: jacket.reviewCount,
      ...(jacket.variants.length > 1
        ? {
            siblings: {
              items: jacket.variants.map((v) => ({
                key: v.slug,
                name: `${jacket.name}, ${v.colour}`,
                cover: { src: image(v.image) },
                colors: { exactColorHex: v.hex, exactColorName: v.colour },
              })),
            },
          }
        : {}),
    }
  }),
)

const productsLabel = (count: number) => `${count} ${count === 1 ? 'product' : 'products'}`
const showLabel = (count: number) => `Show ${productsLabel(count)}`

const setOf = (field: string) => (field === 'colour' ? state.colours : field === 'size' ? state.sizes : undefined)

function addFilter(field: string, value: UiFilterValue) {
  const set = setOf(field)
  if (set) set.add(String(value))
  else if (field === 'price' && typeof value === 'object') state.price = value
  shown.value = PAGE
}

function removeFilter(field: string, value?: string) {
  const set = setOf(field)
  if (set) {
    if (value === undefined) set.clear()
    else set.delete(value)
  } else if (field === 'price') state.price = {}
  shown.value = PAGE
}

function reset() {
  state.colours.clear()
  state.sizes.clear()
  state.price = {}
  state.search = ''
  state.sort = 'default'
  shown.value = PAGE
}
</script>

<template>
  <FilterToolbar
    v-model:search-term="state.search"
    :filters="filters"
    :active-filters="activeFilters"
    :sort-options="sortOptions"
    :active-sorting="state.sort"
    :products="products"
    category="Jackets"
    :total-results="results.length"
    :results-label="productsLabel"
    :show-results-label="showLabel"
    :swatch-colors="swatchColors"
    @add-filter="addFilter"
    @remove-filter="removeFilter"
    @sort-result="(sort?: string) => (state.sort = sort ?? 'default')"
    @reset-filter="(field?: string) => field && removeFilter(field)"
    @reset="reset"
    @load-more="shown += PAGE"
  />
</template>
```

## API reference

### FilterToolbar

Shows a product list with filters in a toolbar above it, plus sorting and search. Renders a `<div>`.

Styling: [`title`](https://ui.frontic.com/DESIGN.md#title); [`hint`](https://ui.frontic.com/DESIGN.md#hint); [`clear-link`](https://ui.frontic.com/DESIGN.md#clear-link). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `filters` | `UiFilter[]` | `[]` | The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. |
| `activeFilters` | `Record<string, unknown>` | `{}` | Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. |
| `sortOptions` | `UiSort[]` | `[]` | The sort orders in the sort select. |
| `activeSorting` | `string` | `'default'` | The `value` of the selected sort option. |
| `searchTerm` | `string` | `''` | The text in the search field; edits emit `update:searchTerm`. |
| `totalResults` | `number` | `0` | Number of products matching the filters; the load-more button shows while fewer are loaded. |
| `category` | `string` | `''` | Shown as the page heading, e.g. the category's name. |
| `products` | `Product[]` | `[]` | The products loaded so far, shown as cards. |
| `currency` | `string` | `'EUR'` | ISO 4217 code the price filter shows its range in. |
| `locale` | `string` |  | BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. |
| `resultsLabel` | `((count: number) => string)`: `(count: number): string` | ``${count} ${count === 1 ? 'product' : 'products'}`` | Builds the results count under the heading from `totalResults`. |
| `filtersLabel` | `string` | `'Filters'` | Shown on the button that opens the filter sheet on small screens. |
| `sheetTitle` | `string` | `'Filters & Sort'` | Shown as the heading of the filter sheet on small screens. |
| `sortPlaceholder` | `string` | `'Sort by'` | Shown in the sort select while no option is selected. |
| `searchPlaceholder` | `string` | `'Search products...'` | Shown in the toolbar's search field on wider screens while it is empty. |
| `mobileSearchPlaceholder` | `string` | `'Search...'` | Shown in the search field on small screens while it is empty. |
| `clearFilterLabel` | `((label: string) => string)`: `(label: string): string` | ``Clear ${label}`` | Builds the label of the button in a filter's dropdown that clears it, from the filter's label. |
| `resetLabel` | `string` | `'Reset'` | Shown on the filter sheet's buttons that clear every filter, the sorting and the search. |
| `showResultsLabel` | `((count: number) => string)`: `(count: number): string` | ``Show Results (${count})`` | Builds the label of the button that closes the filter sheet from `totalResults`. |
| `removeLabel` | `((label: string) => string)`: `(label: string): string` | ``Remove ${label}`` | Builds the screen-reader name of an active filter's remove button from the option's label. |
| `activeFiltersLabel` | `string` | `'Active filters'` | Names the list of active filters for screen readers. |
| `clearAllLabel` | `string` | `'Clear all'` | Shown on the link next to the active filters and on the empty state's button; both clear every filter. |
| `emptyTitle` | `string` | `'No products match these filters'` | Shown as the heading when no product matches the filters. |
| `emptyText` | `string` | `'Remove a filter or clear them all to see more.'` | Shown under the heading when no product matches the filters. |
| `loadMoreLabel` | `string` | `'Load more'` | Shown on the button under the grid that loads more products. |
| `swatchColors` | `Record<string, string>` | `{ ...DEFAULT_SWATCH_COLORS }` | Swatch colour per colour-filter value, keyed lowercase. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Event | Payload | Description |
| --- | --- | --- |
| `reset` |  | Fires when the shopper resets all filters. |
| `addFilter` | `field: string, value: UiFilterValue` | Fires with the filter key and its new value when the shopper selects an option, turns on a switch or sets a range. |
| `removeFilter` | `field: string, value?: string \| undefined` | Fires with the filter key, plus the option value for a single option, when the shopper deselects or clears a filter. |
| `sortResult` | `sortBy?: string \| undefined` | Fires with the chosen sort key, or undefined for the default order, when the shopper changes the sorting. |
| `loadMore` |  | Fires when the shopper clicks the load-more button. |
| `update:searchTerm` | `term: string` | Fires with the new value when `searchTerm` changes; `v-model:searchTerm` binds it. |
| `resetFilter` | `field?: string \| undefined` | Fires with the filter key when the shopper clears a single filter. |

## Exports

```ts
import { type Product, type UiFilter, type UiFilterOption, type UiFilterStats, type UiFilterValue, type UiSort } from '@/components/templates/filter-toolbar'
```

- `Product`: from [Product Card Basic](https://ui.frontic.com/components/sections/product-card-basic.md)
- `UiFilter`, `UiFilterOption`, `UiFilterStats`, `UiFilterValue`, `UiSort`: from [Filter Model](https://ui.frontic.com/lib/filter-model.md)

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`filter-toolbar`](https://ui.frontic.com/DESIGN.md#filter-toolbar) |
| `bar` | [`filter-toolbar-bar`](https://ui.frontic.com/DESIGN.md#filter-toolbar-bar) |
| `title` | [`title`](https://ui.frontic.com/DESIGN.md#title) |
| `hint` | [`hint`](https://ui.frontic.com/DESIGN.md#hint) |
| `clear-link` | [`clear-link`](https://ui.frontic.com/DESIGN.md#clear-link) |
| `focus-ring` | [`focus-ring`](https://ui.frontic.com/DESIGN.md#focus-ring) |

States with a look of their own: hover and focus.

## Source

`components/templates/filter-toolbar/FilterToolbar.vue`

```vue
<script lang="ts">
/** Shows a product list with filters in a toolbar above it, plus sorting and search. */
export default {}
</script>

<script setup lang="ts">
import { SearchIcon, ChevronDownIcon, SlidersHorizontalIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import type { Product, UiFilter, UiFilterValue, UiSort } from './types'
import { computed, ref } from 'vue'
import { useFilterState } from '@library/composables/use-filter-state'
import { activeBoolean, activeRange, activeRating, DEFAULT_SWATCH_COLORS } from '@library/lib/filter-model'
import { Badge } from '@library/components/ui/badge'
import { TagGroup, TagGroupItem, TagGroupItemDelete, TagGroupItemText } from '@library/components/ui/tag-group'
import { Button } from '@library/components/ui/button'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@library/components/ui/empty'
import { Filter, FilterGroup, FilterList, FilterPrice, FilterRating, FilterReset, FilterSwatch, FilterSwitch } from '@library/components/ui/filter'
import { Input } from '@library/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@library/components/ui/popover'
import { ScrollArea } from '@library/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@library/components/ui/select'
import { Separator } from '@library/components/ui/separator'
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@library/components/ui/sheet'
import { ProductCardBasic } from '@library/components/sections/product-card-basic'

const props = withDefaults(
  defineProps<{
    /** The filters to offer; `type` picks the control: `boolean`, `rating`, `color`, `range` or an option list. */
    filters?: UiFilter[]
    /** Applied values by filter key for switch, rating and range filters; list options carry `selected` themselves. */
    activeFilters?: Record<string, unknown>
    /** The sort orders in the sort select. */
    sortOptions?: UiSort[]
    /** The `value` of the selected sort option. */
    activeSorting?: string
    /** The text in the search field; edits emit `update:searchTerm`. */
    searchTerm?: string
    /** Number of products matching the filters; the load-more button shows while fewer are loaded. */
    totalResults?: number
    /** Shown as the page heading, e.g. the category's name. */
    category?: string
    /** The products loaded so far, shown as cards. */
    products?: Product[]
    /** ISO 4217 code the price filter shows its range in. */
    currency?: string
    /** BCP 47 locale the prices are formatted in; the ConfigProvider's when left out. */
    locale?: string
    /** Builds the results count under the heading from `totalResults`. */
    resultsLabel?: (count: number) => string
    /** Shown on the button that opens the filter sheet on small screens. */
    filtersLabel?: string
    /** Shown as the heading of the filter sheet on small screens. */
    sheetTitle?: string
    /** Shown in the sort select while no option is selected. */
    sortPlaceholder?: string
    /** Shown in the toolbar's search field on wider screens while it is empty. */
    searchPlaceholder?: string
    /** Shown in the search field on small screens while it is empty. */
    mobileSearchPlaceholder?: string
    /** Builds the label of the button in a filter's dropdown that clears it, from the filter's label. */
    clearFilterLabel?: (label: string) => string
    /** Shown on the filter sheet's buttons that clear every filter, the sorting and the search. */
    resetLabel?: string
    /** Builds the label of the button that closes the filter sheet from `totalResults`. */
    showResultsLabel?: (count: number) => string
    /** Builds the screen-reader name of an active filter's remove button from the option's label. */
    removeLabel?: (label: string) => string
    /** Names the list of active filters for screen readers. */
    activeFiltersLabel?: string
    /** Shown on the link next to the active filters and on the empty state's button; both clear every filter. */
    clearAllLabel?: string
    /** Shown as the heading when no product matches the filters. */
    emptyTitle?: string
    /** Shown under the heading when no product matches the filters. */
    emptyText?: string
    /** Shown on the button under the grid that loads more products. */
    loadMoreLabel?: string
    /** Swatch colour per colour-filter value, keyed lowercase. */
    swatchColors?: Record<string, string>
    class?: HTMLAttributes['class']
  }>(),
  {
    filters: () => [],
    activeFilters: () => ({}),
    sortOptions: () => [],
    activeSorting: 'default',
    searchTerm: '',
    totalResults: 0,
    category: '',
    products: () => [],
    currency: 'EUR',
    resultsLabel: (count: number) => `${count} ${count === 1 ? 'product' : 'products'}`,
    filtersLabel: 'Filters',
    sheetTitle: 'Filters & Sort',
    sortPlaceholder: 'Sort by',
    searchPlaceholder: 'Search products...',
    mobileSearchPlaceholder: 'Search...',
    clearFilterLabel: (label: string) => `Clear ${label}`,
    resetLabel: 'Reset',
    showResultsLabel: (count: number) => `Show Results (${count})`,
    removeLabel: (label: string) => `Remove ${label}`,
    activeFiltersLabel: 'Active filters',
    clearAllLabel: 'Clear all',
    emptyTitle: 'No products match these filters',
    emptyText: 'Remove a filter or clear them all to see more.',
    loadMoreLabel: 'Load more',
    swatchColors: () => ({ ...DEFAULT_SWATCH_COLORS }),
  },
)

const emit = defineEmits<{
  /** Fires with the filter key and its new value when the shopper selects an option, turns on a switch or sets a range. */
  addFilter: [field: string, value: UiFilterValue]
  /** Fires with the filter key, plus the option value for a single option, when the shopper deselects or clears a filter. */
  removeFilter: [field: string, value?: string]
  /** Fires with the filter key when the shopper clears a single filter. */
  resetFilter: [field?: string]
  /** Fires with the chosen sort key, or undefined for the default order, when the shopper changes the sorting. */
  sortResult: [sortBy?: string]
  'update:searchTerm': [term: string]
  /** Fires when the shopper resets all filters. */
  reset: []
  /** Fires when the shopper clicks the load-more button. */
  loadMore: []
}>()

const {
  localFilters,
  localSorting,
  localSearch,
  activeCount,
  activeBadges,
  swatches,
  selectedCount,
  toggleFilterOption,
  handleToggle,
  handleRating,
  handleRange,
  handleSort,
  handleSearch,
  removeTags,
  resetAll,
} = useFilterState(
  {
    filters: () => props.filters,
    activeFilters: () => props.activeFilters,
    activeSorting: () => props.activeSorting,
    searchTerm: () => props.searchTerm,
    swatchColors: () => props.swatchColors,
  },
  {
    add: (field, value) => emit('addFilter', field, value),
    remove: (field, value) => emit('removeFilter', field, value),
    sort: (sortBy) => emit('sortResult', sortBy),
    search: (term) => emit('update:searchTerm', term),
    reset: () => emit('reset'),
  },
)

const mobileSheetOpen = ref(false)

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'))
const rangeFilters = computed(() => localFilters.value.filter((f) => f.type === 'range'))
const rangeActive = (key: string) => activeRange(props.activeFilters, key).from !== undefined || activeRange(props.activeFilters, key).to !== undefined

function clearFilter(filter: UiFilter) {
  for (const o of filter.options) o.selected = false
  emit('resetFilter', filter.key)
}
</script>

<template>
  <div :class="['fui-filter-toolbar @container', props.class]">
    <div class="container mx-auto flex flex-col gap-4 px-4 py-6 @3xl:px-6">
      <div>
        <h1 data-slot="title" class="fui-title">{{ category }}</h1>
        <p data-slot="hint" class="fui-hint">
          {{ resultsLabel(totalResults) }}
        </p>
      </div>

      <div data-slot="filter-toolbar-bar" class="fui-filter-toolbar-bar hidden items-center @3xl:flex">
        <div class="relative flex-1">
          <span data-slot="hint" class="fui-hint absolute top-1/2 inset-s-3 flex -translate-y-1/2">
            <SearchIcon class="size-4" />
          </span>
          <Input :model-value="localSearch" :placeholder="searchPlaceholder" class="ps-10" @update:model-value="handleSearch" />
        </div>

        <Separator orientation="vertical" class="h-8" />

        <Popover v-for="filter in popoverFilters" :key="filter.key">
          <PopoverTrigger as-child>
            <Button variant="outline" color="neutral" size="sm">
              {{ filter.label }}
              <Badge v-if="filter.options.some((o) => o.selected)" size="sm" class="ms-0.5">{{ filter.options.filter((o) => o.selected).length }}</Badge>
              <ChevronDownIcon class="size-3 opacity-50" />
            </Button>
          </PopoverTrigger>
          <PopoverContent class="w-56 p-2" align="start">
            <FilterSwatch v-if="filter.type === 'color'" :options="swatches(filter)" 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" color="neutral" size="sm" class="w-full" @click="clearFilter(filter)">{{ clearFilterLabel(filter.label) }}</Button>
          </PopoverContent>
        </Popover>

        <Popover v-for="filter in rangeFilters" :key="filter.key">
          <PopoverTrigger as-child>
            <Button variant="outline" color="neutral" size="sm">
              {{ filter.label }}
              <ChevronDownIcon class="size-3 opacity-50" />
            </Button>
          </PopoverTrigger>
          <PopoverContent class="w-64 p-4" align="start">
            <FilterPrice
              :aria-label="filter.label"
              :currency="currency"
              :locale="locale"
              :precision="0"
              :min="filter.data?.total.min ?? 0"
              :max="filter.data?.total.max ?? 1000"
              :step="10"
              :value="{
                to: activeRange(activeFilters, filter.key).to ?? filter.data?.total.max ?? 1000,
              }"
              @change="handleRange(filter.key, $event)"
            />
            <Button v-if="rangeActive(filter.key)" variant="ghost" color="neutral" size="sm" class="mt-2 w-full" @click="handleRange(filter.key, {})">{{ clearFilterLabel(filter.label) }}</Button>
          </PopoverContent>
        </Popover>

        <Separator orientation="vertical" class="h-8" />

        <div class="w-42.5 shrink-0">
          <Select :model-value="localSorting" @update:model-value="handleSort">
            <SelectTrigger class="w-full">
              <SelectValue :placeholder="sortPlaceholder" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem v-for="opt in sortOptions" :key="opt.key" :value="opt.value">{{ opt.label }}</SelectItem>
            </SelectContent>
          </Select>
        </div>
      </div>

      <div v-if="toggleFilters.length > 0 || ratingFilters.length > 0" class="hidden items-center gap-6 @3xl:flex">
        <div v-for="toggle in toggleFilters" :key="toggle.key" class="w-48">
          <FilterSwitch :value="activeBoolean(activeFilters, toggle.key)" :label="toggle.label" @change="handleToggle(toggle.key, $event)" />
        </div>
        <Separator v-if="toggleFilters.length > 0 && ratingFilters.length > 0" orientation="vertical" class="h-8" />
        <FilterRating v-for="rating in ratingFilters" :key="rating.key" :value="activeRating(activeFilters, rating.key)" :label="rating.label" :max="5" @change="handleRating(rating.key, $event)" />
      </div>

      <div class="flex items-center gap-2 @3xl:hidden">
        <div class="relative flex-1">
          <span data-slot="hint" class="fui-hint absolute top-1/2 inset-s-3 flex -translate-y-1/2">
            <SearchIcon class="size-4" />
          </span>
          <Input :model-value="localSearch" :placeholder="mobileSearchPlaceholder" class="ps-10" @update:model-value="handleSearch" />
        </div>
        <Sheet v-model:open="mobileSheetOpen">
          <SheetTrigger as-child>
            <Button variant="outline" color="neutral">
              <SlidersHorizontalIcon data-icon />
              {{ filtersLabel }}
              <Badge v-if="activeCount > 0" size="sm" class="ms-1">{{ activeCount }}</Badge>
            </Button>
          </SheetTrigger>
          <SheetContent side="bottom" class="max-h-17/20">
            <SheetHeader>
              <SheetTitle>{{ sheetTitle }}</SheetTitle>
            </SheetHeader>
            <ScrollArea class="-mx-6 flex-1 px-6">
              <Filter :reset="resetAll" class="space-y-4 pb-6">
                <Select :model-value="localSorting" @update:model-value="handleSort">
                  <SelectTrigger class="w-full">
                    <SelectValue :placeholder="sortPlaceholder" />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem v-for="opt in sortOptions" :key="opt.key" :value="opt.value">{{ opt.label }}</SelectItem>
                  </SelectContent>
                </Select>

                <Separator />

                <template v-for="filter in localFilters" :key="filter.key">
                  <FilterSwitch v-if="filter.type === 'boolean'" :value="activeBoolean(activeFilters, filter.key)" :label="filter.label" @change="handleToggle(filter.key, $event)" />
                  <FilterGroup v-else-if="filter.type === 'rating'" type="collapsible" :label="filter.label">
                    <FilterRating :value="activeRating(activeFilters, filter.key)" :max="5" @change="handleRating(filter.key, $event)" />
                  </FilterGroup>
                  <FilterGroup v-else-if="filter.type === 'color'" type="collapsible" :label="filter.label" :active-count="selectedCount(filter)" :default-open="!!selectedCount(filter)">
                    <FilterSwatch :options="swatches(filter)" @toggle="toggleFilterOption(filter.key, $event)" />
                  </FilterGroup>
                  <FilterGroup v-else-if="filter.type === 'range'" type="collapsible" :label="filter.label">
                    <FilterPrice
                      :currency="currency"
                      :locale="locale"
                      :precision="0"
                      :min="filter.data?.total.min ?? 0"
                      :max="filter.data?.total.max ?? 1000"
                      :step="10"
                      :value="{
                        to: activeRange(activeFilters, filter.key).to ?? filter.data?.total.max ?? 1000,
                      }"
                      @change="handleRange(filter.key, $event)"
                    />
                  </FilterGroup>
                  <FilterGroup v-else type="collapsible" :label="filter.label" :active-count="selectedCount(filter)" :default-open="!!selectedCount(filter)">
                    <FilterList :options="filter.options" @toggle="toggleFilterOption(filter.key, $event)" />
                  </FilterGroup>
                </template>

                <FilterReset :label="resetLabel" />
              </Filter>
            </ScrollArea>
            <div>
              <Separator />
              <SheetFooter class="flex-row">
                <Button variant="outline" color="neutral" class="flex-1" :disabled="activeCount === 0" @click="resetAll">{{ resetLabel }}</Button>
                <Button class="flex-1" @click="mobileSheetOpen = false">{{ showResultsLabel(totalResults) }}</Button>
              </SheetFooter>
            </div>
          </SheetContent>
        </Sheet>
      </div>

      <div v-if="activeBadges.length > 0" class="flex flex-wrap items-center gap-2">
        <TagGroup :aria-label="activeFiltersLabel" @remove="removeTags">
          <TagGroupItem v-for="badge in activeBadges" :key="`${badge.field}-${badge.value}`" :value="`${badge.field}-${badge.value}`">
            <TagGroupItemText>{{ badge.label }}</TagGroupItemText>
            <TagGroupItemDelete :aria-label="removeLabel(badge.label)" :aria-labelledby="undefined" />
          </TagGroupItem>
        </TagGroup>
        <button type="button" data-slot="clear-link" class="fui-clear-link outline-none focus-visible:fui-focus-ring" @click="resetAll">
          {{ clearAllLabel }}
        </button>
      </div>

      <div v-if="products.length" class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @3xl:grid-cols-3 @5xl:grid-cols-4">
        <ProductCardBasic v-for="(product, index) in products" :key="product.key ?? index" :product="product" :locale="locale" />
      </div>

      <Empty v-else>
        <EmptyHeader>
          <EmptyTitle>{{ emptyTitle }}</EmptyTitle>

          <EmptyDescription>{{ emptyText }}</EmptyDescription>
        </EmptyHeader>

        <EmptyContent>
          <Button variant="outline" color="neutral" @click="resetAll">{{ clearAllLabel }}</Button>
        </EmptyContent>
      </Empty>

      <div v-if="products.length < totalResults" class="mt-4 flex justify-center">
        <Button variant="outline" color="neutral" @click="emit('loadMore')">{{ loadMoreLabel }}</Button>
      </div>
    </div>
  </div>
</template>
```

`components/templates/filter-toolbar/types.ts`

```ts
export type { Product } from '@library/components/sections/product-card-basic'
export type { UiFilter, UiFilterOption, UiFilterStats, UiFilterValue, UiSort } from '@library/lib/filter-model'
```
