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/reactpnpm add @floating-ui/reactyarn add @floating-ui/reactbun add @floating-ui/reactnpm install reka-uipnpm add reka-uiyarn add reka-uibun add reka-uiInstallation
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
| Prop | Default | Description |
|---|---|---|
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. |
| Prop | Default | Description |
|---|---|---|
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:
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:
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>