Skip to content
  1. Tables
  2. Cascade filter

Cascade filter

A filter on a value, then on a narrower value inside it, picked from two searchable lists in one popover.

'use client'

import { CascadeFilter, type CascadeOption } from '@/components/cascade-filter'
import { LanguageIcon } from '@heroicons/react/16/solid'
import * as flags from 'country-flag-icons/string/1x1'
import { useState } from 'react'

function Flag({ code }: { code: keyof typeof flags }) {
  return (
    <span
      aria-hidden="true"
      className="size-4.5 shrink-0 overflow-hidden rounded-full ring-1 ring-zinc-950/10 *:size-full dark:ring-white/10"
      dangerouslySetInnerHTML={{ __html: flags[code] }}
    />
  )
}

const languages: CascadeOption[] = [
  { value: 'af', label: 'Afrikaans', icon: <Flag code="ZA" /> },
  {
    value: 'en',
    label: 'English',
    icon: <Flag code="GB" />,
    options: [
      { value: 'au', label: 'Australia' },
      { value: 'gb', label: 'United Kingdom' },
      { value: 'us', label: 'United States' },
    ],
  },
  {
    value: 'fr',
    label: 'French',
    icon: <Flag code="FR" />,
    options: [
      { value: 'be', label: 'Belgium' },
      { value: 'ca', label: 'Canada' },
      { value: 'fr', label: 'France' },
    ],
  },
  { value: 'ja', label: 'Japanese', icon: <Flag code="JP" /> },
]

export default function Example() {
  let [locale, setLocale] = useState<string[]>([])

  return (
    <CascadeFilter
      label="Language"
      icon={LanguageIcon}
      options={languages}
      value={locale}
      onChange={setLocale}
      searchPlaceholder="Search languages"
      childLabel="Select region"
      childSearchPlaceholder="Search regions"
    />
  )
}
<script setup lang="ts">
import CascadeFilter, { type CascadeOption } from '@/components/cascade-filter.vue'
import { LanguageIcon } from '@heroicons/vue/16/solid'
import * as flags from 'country-flag-icons/string/1x1'
import { h, ref } from 'vue'

function flag(code: keyof typeof flags) {
  return () =>
    h('span', {
      'aria-hidden': 'true',
      class: 'size-4.5 shrink-0 overflow-hidden rounded-full ring-1 ring-zinc-950/10 *:size-full dark:ring-white/10',
      innerHTML: flags[code],
    })
}

const languages: CascadeOption[] = [
  { value: 'af', label: 'Afrikaans', icon: flag('ZA') },
  {
    value: 'en',
    label: 'English',
    icon: flag('GB'),
    options: [
      { value: 'au', label: 'Australia' },
      { value: 'gb', label: 'United Kingdom' },
      { value: 'us', label: 'United States' },
    ],
  },
  {
    value: 'fr',
    label: 'French',
    icon: flag('FR'),
    options: [
      { value: 'be', label: 'Belgium' },
      { value: 'ca', label: 'Canada' },
      { value: 'fr', label: 'France' },
    ],
  },
  { value: 'ja', label: 'Japanese', icon: flag('JP') },
]

const locale = ref<string[]>([])
</script>

<template>
  <CascadeFilter
    v-model="locale"
    label="Language"
    :icon="LanguageIcon"
    :options="languages"
    search-placeholder="Search languages"
    child-label="Select region"
    child-search-placeholder="Search regions"
  />
</template>

Dependencies

Positioning comes from Floating UI, which Headless UI already depends on. Install it explicitly so the import does not rely on hoisting:The popover and its positioning come from Reka UI, which the kit already depends on:

npm install @floating-ui/react
pnpm add @floating-ui/react
yarn add @floating-ui/react
bun add @floating-ui/react
npm install reka-ui
pnpm add reka-ui
yarn add reka-ui
bun add reka-ui

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import {
  autoUpdate,
  flip,
  FloatingFocusManager,
  FloatingPortal,
  offset,
  shift,
  useDismiss,
  useFloating,
  useInteractions,
} from '@floating-ui/react'
import { CheckIcon, ChevronDownIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useId, useMemo, useRef, useState } from 'react'

type CascadeChoice = { value: string; label: string; icon?: React.ReactNode }
export type CascadeOption = CascadeChoice & { options?: CascadeChoice[] }

function normalize(value: string) {
  return value
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/gu, '')
}

function CascadeTrigger({
  label,
  icon,
  selected,
  open,
  buttonRef,
  onToggle,
  onClear,
}: {
  label: string
  icon?: React.ReactNode
  selected?: CascadeChoice
  open: boolean
  buttonRef: React.Ref<HTMLButtonElement>
  onToggle: () => void
  onClear: () => void
}) {
  let leading = selected ? selected.icon : icon

  return (
    <div
      className={clsx(
        'flex h-10 shrink-0 items-center rounded-[10px]',
        'has-[[data-slot=trigger]:focus-visible]:outline-2 has-[[data-slot=trigger]:focus-visible]:outline-offset-2 has-[[data-slot=trigger]:focus-visible]:outline-accent-500',
        selected
          ? 'border-[1.5px] border-accent-500'
          : 'border border-zinc-950/12 hover:bg-zinc-950/5 active:bg-zinc-950/10 dark:border-white/10 dark:hover:bg-white/5 dark:active:bg-white/10'
      )}
    >
      <button
        ref={buttonRef}
        type="button"
        data-slot="trigger"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-label={selected ? `${label}: ${selected.label}` : undefined}
        onClick={onToggle}
        className={clsx(
          'flex h-full cursor-pointer items-center text-base/6 font-medium whitespace-nowrap text-zinc-950 outline-none sm:text-sm/6 dark:text-white',
          selected ? clsx('gap-1.5 pr-1', leading ? 'pl-1.5' : 'pl-3') : 'gap-2 px-3'
        )}
      >
        {leading}
        {selected?.label ?? label}
        {!selected && (
          <ChevronDownIcon
            className={clsx('-ml-0.5 size-4 shrink-0 text-zinc-500 dark:text-zinc-400', open && 'rotate-180')}
          />
        )}
      </button>
      {selected && (
        <button
          type="button"
          onClick={onClear}
          className="mr-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 dark:text-white dark:hover:bg-white/10"
        >
          <XMarkIcon className="size-4" />
          <span className="sr-only">Remove {selected.label}</span>
        </button>
      )}
    </div>
  )
}

function CascadeColumn({
  label,
  options,
  selected,
  placeholder,
  inputRef,
  onSelect,
  className,
}: {
  label: string
  options: CascadeChoice[]
  selected?: string
  placeholder: string
  inputRef: React.Ref<HTMLInputElement>
  onSelect: (value: string) => void
  className?: string
}) {
  let [query, setQuery] = useState('')
  let [active, setActive] = useState(0)
  let [keyboard, setKeyboard] = useState(false)
  let list = useRef<HTMLDivElement>(null)
  let listId = useId()

  let filtered = useMemo(() => {
    let normalized = normalize(query.trim())
    if (normalized === '') return options
    return options.filter((option) => normalize(option.label).includes(normalized))
  }, [options, query])

  function highlight(index: number) {
    let next = Math.min(Math.max(index, 0), filtered.length - 1)
    setActive(next)
    setKeyboard(true)
    document.getElementById(`${listId}-${next}`)?.scrollIntoView({ block: 'nearest' })
  }

  return (
    <div className={clsx(className, 'flex min-w-0 flex-col')}>
      <div className="flex h-10 shrink-0 items-center gap-2 px-3">
        <MagnifyingGlassIcon className="size-4.5 shrink-0 fill-zinc-500 dark:fill-zinc-400" />
        <input
          ref={inputRef}
          type="text"
          role="combobox"
          aria-expanded="true"
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={filtered[active] ? `${listId}-${active}` : undefined}
          aria-label={placeholder}
          placeholder={placeholder}
          autoComplete="off"
          spellCheck={false}
          value={query}
          onChange={(event) => {
            setQuery(event.target.value)
            setActive(0)
            list.current?.scrollTo({ top: 0 })
          }}
          onBlur={() => setKeyboard(false)}
          onKeyDown={(event) => {
            if (event.nativeEvent.isComposing || event.keyCode === 229) return
            if (event.key === 'ArrowDown') {
              event.preventDefault()
              highlight(active + 1)
            } else if (event.key === 'ArrowUp') {
              event.preventDefault()
              highlight(active - 1)
            } else if (event.key === 'Enter') {
              event.preventDefault()
              let option = filtered[active]
              if (option) onSelect(option.value)
            }
          }}
          className="w-full min-w-0 bg-transparent text-base/6 text-zinc-950 outline-none placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:placeholder:text-zinc-400"
        />
      </div>
      <div ref={list} className="min-h-0 flex-1 overflow-y-auto p-1">
        <div role="status">
          {filtered.length === 0 && (
            <p className="px-2 py-1.5 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">No results</p>
          )}
        </div>
        <div id={listId} role="listbox" aria-label={label}>
          {filtered.map((option, index) => (
            <div
              key={option.value}
              id={`${listId}-${index}`}
              role="option"
              aria-selected={option.value === selected}
              data-active={index === active ? '' : undefined}
              data-focus={index === active && keyboard ? '' : undefined}
              title={option.label}
              onMouseMove={() => {
                setActive(index)
                setKeyboard(false)
              }}
              onMouseDown={(event) => event.preventDefault()}
              onClick={() => onSelect(option.value)}
              className="flex h-8 cursor-pointer items-center gap-3 rounded-lg pr-3 pl-2 text-base/6 text-zinc-950 select-none data-active:bg-zinc-950/5 data-focus:outline-2 data-focus:-outline-offset-2 data-focus:outline-accent-500 sm:text-sm/6 dark:text-white dark:data-active:bg-white/5"
            >
              {option.icon}
              <span className="min-w-0 flex-1 truncate">{option.label}</span>
              {option.value === selected && <CheckIcon className="size-4.5 shrink-0" />}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

export function CascadeFilter({
  label,
  icon: Icon,
  options,
  value,
  onChange,
  searchPlaceholder = 'Search',
  childLabel = 'Select',
  childSearchPlaceholder = 'Search',
  className,
  ...props
}: {
  label: string
  icon?: React.ComponentType<{ className?: string }>
  options: CascadeOption[]
  value: string[]
  onChange: (value: string[]) => void
  searchPlaceholder?: string
  childLabel?: string
  childSearchPlaceholder?: string
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange' | 'children'>) {
  let [open, setOpen] = useState(false)
  let [focusChild, setFocusChild] = useState(false)
  let parentButton = useRef<HTMLButtonElement>(null)
  let childButton = useRef<HTMLButtonElement>(null)
  let parentSearch = useRef<HTMLInputElement>(null)
  let childSearch = useRef<HTMLInputElement>(null)

  let parent = options.find((option) => option.value === value[0])
  let childOptions = parent?.options?.length ? parent.options : undefined
  let child = childOptions?.find((option) => option.value === value[1])
  let openedFromChild = focusChild && childOptions !== undefined

  let {
    refs: { setReference, setFloating },
    floatingStyles,
    context,
  } = useFloating({
    open,
    onOpenChange: setOpen,
    placement: 'bottom-start',
    middleware: [offset(8), flip(), shift({ padding: 8 })],
    whileElementsMounted: autoUpdate,
  })
  let { getFloatingProps } = useInteractions([useDismiss(context)])

  function toggle(fromChild: boolean) {
    setFocusChild(fromChild)
    setOpen(!open)
  }

  return (
    <>
      <div ref={setReference} {...props} className={clsx(className, 'flex items-center gap-1')}>
        <span role="status" className="sr-only">
          {parent && `${label}: ${[parent.label, child?.label].filter(Boolean).join(', ')}`}
        </span>
        <CascadeTrigger
          label={label}
          icon={Icon && <Icon className="size-4 shrink-0" />}
          selected={parent}
          open={open}
          buttonRef={parentButton}
          onToggle={() => toggle(false)}
          onClear={() => {
            onChange([])
            parentButton.current?.focus()
          }}
        />
        {parent && childOptions && (
          <CascadeTrigger
            label={childLabel}
            selected={child}
            open={open}
            buttonRef={childButton}
            onToggle={() => toggle(true)}
            onClear={() => {
              onChange([parent.value])
              childButton.current?.focus()
            }}
          />
        )}
      </div>
      {open && (
        <FloatingPortal>
          <FloatingFocusManager
            context={context}
            modal={false}
            initialFocus={openedFromChild ? childSearch : parentSearch}
            returnFocus={openedFromChild ? childButton : parentButton}
          >
            <div
              ref={setFloating}
              style={floatingStyles}
              {...getFloatingProps()}
              role="dialog"
              aria-label={label}
              className="z-50 flex h-80 max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
            >
              <CascadeColumn
                label={label}
                options={options}
                selected={value[0]}
                placeholder={searchPlaceholder}
                inputRef={parentSearch}
                onSelect={(next) => {
                  parentSearch.current?.focus()
                  if (next !== value[0]) onChange([next])
                }}
                className={childOptions ? 'w-50' : 'w-60'}
              />
              {parent && childOptions && (
                <CascadeColumn
                  key={parent.value}
                  label={childLabel}
                  options={childOptions}
                  selected={value[1]}
                  placeholder={childSearchPlaceholder}
                  inputRef={childSearch}
                  onSelect={(next) => onChange([parent.value, next])}
                  className="w-50 border-l border-zinc-950/10 dark:border-white/10"
                />
              )}
            </div>
          </FloatingFocusManager>
        </FloatingPortal>
      )}
    </>
  )
}
<script setup lang="ts">
import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
import { computed, ref, type Component } from 'vue'
import CascadeFilterColumn from './cascade-filter-column.vue'
import CascadeFilterTrigger from './cascade-filter-trigger.vue'

defineOptions({ inheritAttrs: false })

type CascadeChoice = { value: string; label: string; icon?: Component }
export type CascadeOption = CascadeChoice & { options?: CascadeChoice[] }

const props = withDefaults(
  defineProps<{
    label: string
    icon?: Component
    options: CascadeOption[]
    searchPlaceholder?: string
    childLabel?: string
    childSearchPlaceholder?: string
  }>(),
  {
    searchPlaceholder: 'Search',
    childLabel: 'Select',
    childSearchPlaceholder: 'Search',
  }
)

const model = defineModel<string[]>({ default: () => [] })

const open = ref(false)
const focusChild = ref(false)
const anchor = ref<InstanceType<typeof PopoverAnchor>>()
const parentTrigger = ref<InstanceType<typeof CascadeFilterTrigger>>()
const childTrigger = ref<InstanceType<typeof CascadeFilterTrigger>>()
const parentColumn = ref<InstanceType<typeof CascadeFilterColumn>>()
const childColumn = ref<InstanceType<typeof CascadeFilterColumn>>()
let interactedOutside = false

const parent = computed(() => props.options.find((option) => option.value === model.value[0]))
const childOptions = computed(() => (parent.value?.options?.length ? parent.value.options : undefined))
const child = computed(() => childOptions.value?.find((option) => option.value === model.value[1]))
const openedFromChild = computed(() => focusChild.value && childOptions.value !== undefined)
const status = computed(() =>
  parent.value ? `${props.label}: ${[parent.value.label, child.value?.label].filter(Boolean).join(', ')}` : ''
)

function toggle(fromChild: boolean): void {
  focusChild.value = fromChild
  open.value = !open.value
}

function selectParent(value: string): void {
  parentColumn.value?.focus()
  if (value !== model.value[0]) model.value = [value]
}

function clearParent(): void {
  model.value = []
  parentTrigger.value?.focus()
}

function clearChild(): void {
  model.value = [model.value[0]]
  childTrigger.value?.focus()
}

function keepOpenOnTriggers(event: Event): void {
  if ((anchor.value?.$el as HTMLElement | undefined)?.contains(event.target as Node)) event.preventDefault()
  else interactedOutside = true
}

function focusSearch(event: Event): void {
  event.preventDefault()
  ;(openedFromChild.value ? childColumn : parentColumn).value?.focus()
}

function leaveOnTab(event: KeyboardEvent): void {
  const searches = (event.currentTarget as HTMLElement).querySelectorAll('input')
  if (event.key !== 'Tab' || event.target !== searches[event.shiftKey ? 0 : searches.length - 1]) return
  event.stopPropagation()
  if (event.shiftKey) {
    event.preventDefault()
  } else {
    const buttons = (anchor.value?.$el as HTMLElement).querySelectorAll('button')
    interactedOutside = true
    buttons[buttons.length - 1]?.focus()
  }
  open.value = false
}

function returnFocus(event: Event): void {
  event.preventDefault()
  if (!interactedOutside) (openedFromChild.value ? childTrigger : parentTrigger).value?.focus()
  interactedOutside = false
}
</script>

<template>
  <PopoverRoot v-model:open="open">
    <PopoverAnchor ref="anchor" v-bind="$attrs" class="flex items-center gap-1">
      <span role="status" class="sr-only">{{ status }}</span>
      <CascadeFilterTrigger
        ref="parentTrigger"
        :label="label"
        :icon="icon"
        :selected="parent"
        :open="open"
        @toggle="toggle(false)"
        @clear="clearParent"
      />
      <CascadeFilterTrigger
        v-if="parent && childOptions"
        ref="childTrigger"
        :label="childLabel"
        :selected="child"
        :open="open"
        @toggle="toggle(true)"
        @clear="clearChild"
      />
    </PopoverAnchor>
    <PopoverPortal>
      <PopoverContent
        align="start"
        :side-offset="8"
        :collision-padding="8"
        :aria-label="label"
        class="z-50 flex h-80 max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
        @open-auto-focus="focusSearch"
        @close-auto-focus="returnFocus"
        @interact-outside="keepOpenOnTriggers"
        @keydown.capture="leaveOnTab"
      >
        <CascadeFilterColumn
          ref="parentColumn"
          :label="label"
          :options="options"
          :selected="model[0]"
          :placeholder="searchPlaceholder"
          :class="childOptions ? 'w-50' : 'w-60'"
          @select="selectParent"
        />
        <CascadeFilterColumn
          v-if="parent && childOptions"
          :key="parent.value"
          ref="childColumn"
          :label="childLabel"
          :options="childOptions"
          :selected="model[1]"
          :placeholder="childSearchPlaceholder"
          class="w-50 border-l border-zinc-950/10 dark:border-white/10"
          @select="(value) => (model = [model[0], value])"
        />
      </PopoverContent>
    </PopoverPortal>
  </PopoverRoot>
</template>

Component API

PropDefaultDescription
CascadeFilterrenders a <div> element
label-Text of the trigger while nothing is picked. Also names the popover.
icon-Icon component shown before the label.
options-Array of `{ value, label, icon?, options? }`. The nested `options` are the second-level choices; an option without them is picked on its own.
value-The picked value, then the picked sub-value: `[]`, `['fr']` or `['fr', 'ca']`.
onChange-Called with the new array. Picking another first-level value drops the sub-value.
searchPlaceholder'Search'Placeholder of the first search box.
childLabel'Select'Text of the second trigger while no sub-value is picked.
childSearchPlaceholder'Search'Placeholder of the second search box.
PropDefaultDescription
CascadeFilterbuilt on the Reka UI <PopoverRoot> component
v-model[]The picked value, then the picked sub-value: `[]`, `['fr']` or `['fr', 'ca']`. Picking another first-level value drops the sub-value.
label-Text of the trigger while nothing is picked. Also names the popover.
icon-Icon component shown before the label.
options-Array of `{ value, label, icon?, options? }`. The nested `options` are the second-level choices; an option without them is picked on its own.
searchPlaceholder'Search'Placeholder of the first search box.
childLabel'Select'Text of the second trigger while no sub-value is picked.
childSearchPlaceholder'Search'Placeholder of the second search box.

Examples

Basic example

Picking a language turns the trigger into a chip and opens a second list with its regions, in the same popover. The search keeps its text after a pick, ignores case and accents, and matches anywhere in the label: fr finds Afrikaans and French. Removing the language also removes the region:

Language: French

Value: ["fr"]

'use client'

import { CascadeFilter, type CascadeOption } from '@/components/cascade-filter'
import { LanguageIcon } from '@heroicons/react/16/solid'
import * as flags from 'country-flag-icons/string/1x1'
import { useState } from 'react'

function Flag({ code }: { code: keyof typeof flags }) {
  return (
    <span
      aria-hidden="true"
      className="size-4.5 shrink-0 overflow-hidden rounded-full ring-1 ring-zinc-950/10 *:size-full dark:ring-white/10"
      dangerouslySetInnerHTML={{ __html: flags[code] }}
    />
  )
}

const languages: CascadeOption[] = [
  { value: 'af', label: 'Afrikaans', icon: <Flag code="ZA" /> },
  {
    value: 'en',
    label: 'English',
    icon: <Flag code="GB" />,
    options: [
      { value: 'au', label: 'Australia' },
      { value: 'gb', label: 'United Kingdom' },
      { value: 'us', label: 'United States' },
    ],
  },
  {
    value: 'fr',
    label: 'French',
    icon: <Flag code="FR" />,
    options: [
      { value: 'be', label: 'Belgium' },
      { value: 'ca', label: 'Canada' },
      { value: 'fr', label: 'France' },
    ],
  },
  { value: 'ja', label: 'Japanese', icon: <Flag code="JP" /> },
]

export default function Example() {
  let [locale, setLocale] = useState<string[]>([])

  return (
    <CascadeFilter
      label="Language"
      icon={LanguageIcon}
      options={languages}
      value={locale}
      onChange={setLocale}
      searchPlaceholder="Search languages"
      childLabel="Select region"
      childSearchPlaceholder="Search regions"
    />
  )
}
<script setup lang="ts">
import CascadeFilter, { type CascadeOption } from '@/components/cascade-filter.vue'
import { LanguageIcon } from '@heroicons/vue/16/solid'
import * as flags from 'country-flag-icons/string/1x1'
import { h, ref } from 'vue'

function flag(code: keyof typeof flags) {
  return () =>
    h('span', {
      'aria-hidden': 'true',
      class: 'size-4.5 shrink-0 overflow-hidden rounded-full ring-1 ring-zinc-950/10 *:size-full dark:ring-white/10',
      innerHTML: flags[code],
    })
}

const languages: CascadeOption[] = [
  { value: 'af', label: 'Afrikaans', icon: flag('ZA') },
  {
    value: 'en',
    label: 'English',
    icon: flag('GB'),
    options: [
      { value: 'au', label: 'Australia' },
      { value: 'gb', label: 'United Kingdom' },
      { value: 'us', label: 'United States' },
    ],
  },
  {
    value: 'fr',
    label: 'French',
    icon: flag('FR'),
    options: [
      { value: 'be', label: 'Belgium' },
      { value: 'ca', label: 'Canada' },
      { value: 'fr', label: 'France' },
    ],
  },
  { value: 'ja', label: 'Japanese', icon: flag('JP') },
]

const locale = ref<string[]>([])
</script>

<template>
  <CascadeFilter
    v-model="locale"
    label="Language"
    :icon="LanguageIcon"
    :options="languages"
    search-placeholder="Search languages"
    child-label="Select region"
    child-search-placeholder="Search regions"
  />
</template>

Without icons

Options without an icon show their label alone, in the list and in the chip. Feedback has no nested options, so picking it ends the filter there:

Category: Billing, Refunds
const categories: CascadeOption[] = [
  {
    value: 'billing',
    label: 'Billing',
    options: [
      { value: 'invoices', label: 'Invoices' },
      { value: 'payments', label: 'Payments' },
      { value: 'refunds', label: 'Refunds' },
    ],
  },
  { value: 'feedback', label: 'Feedback' },
]

<CascadeFilter
  label="Category"
  icon={TagIcon}
  options={categories}
  value={category}
  onChange={setCategory}
  searchPlaceholder="Search categories"
  childLabel="Select topic"
  childSearchPlaceholder="Search topics"
/>
<script setup lang="ts">
import CascadeFilter, { type CascadeOption } from '@/components/cascade-filter.vue'
import { TagIcon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'

const categories: CascadeOption[] = [
  {
    value: 'billing',
    label: 'Billing',
    options: [
      { value: 'invoices', label: 'Invoices' },
      { value: 'payments', label: 'Payments' },
      { value: 'refunds', label: 'Refunds' },
    ],
  },
  { value: 'feedback', label: 'Feedback' },
]

const category = ref<string[]>([])
</script>

<template>
  <CascadeFilter
    v-model="category"
    label="Category"
    :icon="TagIcon"
    :options="categories"
    search-placeholder="Search categories"
    child-label="Select topic"
    child-search-placeholder="Search topics"
  />
</template>