<script setup lang="ts">
import { today } from "@internationalized/date";
import type { Filter, FiltersSize, FiltersVariant } from "@/components/filters";
import { Field, Filters, FiltersClear, FiltersItem, FiltersMenu, FiltersMenuContent, FiltersMenuTrigger, FiltersProvider, Operator } from "@/components/filters";
import { CalendarIcon, CircleDashedIcon, CircleIcon, CircleMinusIcon, CircleOffIcon, DollarSignIcon, SearchIcon, TagIcon, ToggleRightIcon } from "lucide-vue-next";
import { type Component, h, type Ref, ref, type VNode } from "vue";
const statusItems = [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
{ label: "Pending", value: "pending" },
{ label: "Cancelled", value: "cancelled" },
];
const statusIcons: Record<string, { icon: Component; class: string }> = {
active: { icon: CircleIcon, class: "size-3.5! rounded-full" },
inactive: { icon: CircleMinusIcon, class: "size-3.5! rounded-full text-muted-foreground" },
pending: { icon: CircleDashedIcon, class: "size-3.5! rounded-full text-yellow-500" },
cancelled: { icon: CircleOffIcon, class: "size-3.5! rounded-full text-red-400" },
};
function statusLabel(value: string): string {
return statusItems.find((item) => item.value === value)?.label ?? value;
}
function renderStatusIcon(value: string): VNode | undefined {
const statusIcon = statusIcons[value];
if (!statusIcon) {
return undefined;
}
return h(statusIcon.icon, { class: [statusIcon.class, "bg-background"], key: value });
}
function renderStatusOption(option: { label: string; value: string }) {
return h("div", { class: "flex items-center gap-2 w-full ml-2" }, [renderStatusIcon(option.value), h("span", option.label), h("span", { class: "text-muted-foreground ml-auto" }, `(${10} issues)`)]);
}
function renderSingleStatusValue(value: string) {
return h("div", { class: "flex items-center gap-2" }, [renderStatusIcon(value), h("span", statusLabel(value))]);
}
function renderManyStatusValues(values: string[]) {
const icons = values
.slice(0, 3)
.map((value) => renderStatusIcon(value))
.filter((icon): icon is VNode => icon !== undefined);
return h("div", { class: "flex items-center gap-2" }, [h("div", { class: "flex -space-x-1" }, icons), `${values.length} ${values.length === 1 ? "status" : "statuses"}`]);
}
const variant = ref<FiltersVariant>("outline");
const size = ref<FiltersSize>("sm");
const fields = ref([
Field.TextField({
key: "q",
label: "Search",
icon: SearchIcon,
operators: [Operator.Contain({ label: "contains" }), Operator.Eq({ label: "is" })],
}),
Field.TextField({
key: "status",
label: "Status",
icon: TagIcon,
operators: [
Operator.Eq({
label: "is",
inputType: "select",
options: {
items: statusItems,
searchable: true,
renderOption: renderStatusOption,
renderValue: renderSingleStatusValue,
},
}),
Operator.In({
label: "any of",
options: {
items: statusItems,
searchable: true,
renderOption: renderStatusOption,
renderValue: renderManyStatusValues,
},
default: true,
}),
],
}),
Field.NumberField({
key: "price",
label: "Price",
icon: DollarSignIcon,
min: 0,
max: 1000,
step: 10,
numberFormat: { style: "currency", currency: "USD", maximumFractionDigits: 0 },
operators: [Operator.Lt({ label: "less than", default: true, defaultValue: 500 }), Operator.Btw({ label: "between" })],
}),
Field.BooleanField({
key: "is_active",
label: "Active",
icon: ToggleRightIcon,
operators: [Operator.Eq({ label: "is", defaultValue: true }), Operator.Null({ label: "is empty" })],
}),
Field.Submenu({
label: "Dates",
icon: CalendarIcon,
fields: [
Field.DateField({
key: "created_at",
label: "Created at",
icon: CalendarIcon,
min: today("UTC").subtract({ days: 30 }),
max: today("UTC").add({ days: 30 }),
operators: [Operator.Eq({ label: "is" }), Operator.Btw({ label: "between" })],
}),
Field.DateField({
key: "updated_at",
label: "Updated at",
icon: CalendarIcon,
min: today("UTC").subtract({ days: 30 }),
max: today("UTC").add({ days: 30 }),
operators: [Operator.Eq({ label: "is" }), Operator.Btw({ label: "between" })],
}),
Field.DateField({
key: "due_date",
label: "Due date",
icon: CalendarIcon,
operators: [
Operator.Eq({
label: "is",
inputType: "select",
options: {
items: [
{ label: "Today", value: today("UTC") },
{ label: "Tomorrow", value: today("UTC").add({ days: 1 }) },
{ label: "Next week", value: today("UTC").add({ days: 7 }) },
{ label: "Next month", value: today("UTC").add({ months: 1 }) },
],
},
}),
],
}),
],
}),
]);
const filters: Ref<Filter[]> = ref([
{ field: "status", operator: "in", value: ["active", "pending"] },
{ field: "price", operator: "btw", value: [100, 500] },
]);
</script>
<template>
<div class="min-w-96 w-full">
<FiltersProvider v-model:filters="filters" :fields="fields" :variant="variant" :size="size">
<FiltersMenu>
<FiltersMenuTrigger />
<FiltersMenuContent />
</FiltersMenu>
<Filters filter-style="long">
<FiltersItem
v-for="filter in filters.filter((current) => !current.hidden)"
:key="`${filter.field}:${filter.operator}`"
:filter="filter"
/>
</Filters>
</FiltersProvider>
</div>
</template>
Features
- Typed field factories.
- Text, number, date, boolean, select, and multi-select values.
- Long or short filter chips.
- Groups and nested menus.
outlineandsecondaryvariants.sm,md, andlgsizes.
Installation
Install from the Vuzeno registry with the shadcn-vue CLI:
bunx --bun shadcn-vue@latest add https://vuzeno.com/r/filters.json
Composition
Use the following composition to build a Filters setup:
FiltersProvider
├── FiltersMenu
│ ├── FiltersMenuTrigger
│ └── FiltersMenuContent
├── Filters
│ └── FiltersItem
└── FiltersClear
Examples
Sizes and variants
Set variant and size on FiltersProvider, use filter-style="short" to show only values.
<script setup lang="ts">
import type { Filter, FiltersSize, FiltersVariant } from "@/components/filters";
import { Field, Filters, FiltersClear, FiltersItem, FiltersMenu, FiltersMenuContent, FiltersMenuTrigger, FiltersProvider, Operator } from "@/components/filters";
import { FieldLabel, Field as UiField } from "@/components/ui/field";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DollarSignIcon, TagIcon, UserIcon } from "lucide-vue-next";
import { type Ref, ref } from "vue";
const variant = ref<FiltersVariant>("outline");
const size = ref<FiltersSize>("sm");
const filterStyle = ref<"short" | "long">("short");
const fields = ref([
Field.TextField({ key: "name", label: "Name", icon: UserIcon, operators: [Operator.Contain({ label: "contains" })] }),
Field.TextField({
key: "status",
label: "Status",
icon: TagIcon,
operators: [
Operator.In({
label: "any of",
options: {
items: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
{ label: "Pending", value: "pending" },
],
},
}),
],
}),
Field.NumberField({
key: "price",
label: "Price",
icon: DollarSignIcon,
min: 0,
max: 1000,
step: 10,
numberFormat: { style: "currency", currency: "USD", maximumFractionDigits: 0 },
operators: [Operator.Btw({ label: "between" })],
}),
]);
const filters: Ref<Filter[]> = ref([
{ field: "name", operator: "contains", value: "Alice" },
{ field: "status", operator: "in", value: ["active", "pending"] },
{ field: "price", operator: "btw", value: [100, 500] },
]);
</script>
<template>
<div class="flex min-w-96 w-full flex-col gap-4">
<div class="flex flex-col gap-4 border border-muted border-dashed rounded-md p-4">
<UiField orientation="vertical" class="w-72 mx-auto">
<Tabs v-model="variant" class="w-full">
<TabsList class="grid w-full grid-cols-2 gap-1 bg-muted p-1">
<TabsTrigger value="outline" class="text-xs">Outline</TabsTrigger>
<TabsTrigger value="secondary" class="text-xs">Secondary</TabsTrigger>
</TabsList>
</Tabs>
</UiField>
<UiField orientation="vertical" class="w-72 mx-auto">
<Tabs v-model="size" class="w-full">
<TabsList class="grid w-full grid-cols-3 gap-1 bg-muted p-1 text-xs">
<TabsTrigger value="sm" class="text-xs">sm</TabsTrigger>
<TabsTrigger value="md" class="text-xs">md</TabsTrigger>
<TabsTrigger value="lg" class="text-xs">lg</TabsTrigger>
</TabsList>
</Tabs>
</UiField>
<UiField orientation="vertical" class="w-72 mx-auto">
<Tabs v-model="filterStyle" class="w-full">
<TabsList class="grid w-full grid-cols-2 gap-1 bg-muted p-1 text-xs">
<TabsTrigger value="short" class="text-xs">short</TabsTrigger>
<TabsTrigger value="long" class="text-xs">long</TabsTrigger>
</TabsList>
</Tabs>
</UiField>
</div>
<FiltersProvider v-model:filters="filters" :fields="fields" :variant="variant" :size="size">
<FiltersMenu>
<FiltersMenuTrigger />
<FiltersMenuContent />
</FiltersMenu>
<Filters :filter-style="filterStyle">
<FiltersItem
v-for="filter in filters"
:key="`${filter.field}:${filter.operator}`"
:filter="filter"
/>
</Filters>
<FiltersClear />
</FiltersProvider>
</div>
</template>
API Reference
Components
| Component | Use |
|---|---|
FiltersProvider | Root state and context. |
FiltersMenu | Add-filter dropdown. |
FiltersMenuTrigger | Default trigger, or custom slot. |
FiltersMenuContent | Field list, groups, and submenus. |
Filters | Chip wrapper. |
FiltersItem | One filter chip. |
FiltersClear | Clears all filters. |
FiltersProvider
| Prop | Type | Default |
|---|---|---|
filters | Filter[] | [] |
fields | FilterFieldItem[] | - |
variant | "outline" | "secondary" | "outline" |
size | "sm" | "md" | "lg" | "md" |
Filters
| Prop | Type | Default |
|---|---|---|
filterStyle | "long" | "short" | "long" |
Fields
Build fields with Field.*.
| Helper | Extra options |
|---|---|
Field.TextField | minLength, maxLength |
Field.NumberField | min, max, step, numberFormat |
Field.DateField | min, max |
Field.BooleanField | trueValue, falseValue |
Field.Group | label, fields |
Field.Submenu | label, icon, fields |
Common field options: key, label, icon, operators.
const fields = [
Field.TextField({
key: "name",
label: "Name",
operators: [
Operator.Contain({ label: "contains" })
],
}),
];
Operators
Build operators with Operator.*.
| Helper | Value | Input |
|---|---|---|
Operator.Eq | eq | input or select |
Operator.Neq | neq | input or select |
Operator.Contain | contains | input |
Operator.NotContain | not_contains | input |
Operator.In | in | multi-select |
Operator.Nin | nin | multi-select |
Operator.Btw | btw | range |
Operator.Null | is_null | none |
Operator.NotNull | not_null | none |
Operator.Gt | gt | input |
Operator.Lt | lt | input |
Common operator options: label, default, defaultValue, options.
Operator.In({
label: "any of",
options: {
items: [
{ label: "Active", value: "active" },
{ label: "Pending", value: "pending" },
],
},
});
Filter
Active filters are plain objects.
const filters: Filter[] = [
{ field: "status", operator: "in", value: ["active", "pending"] },
{ field: "price", operator: "btw", value: [100, 500] },
];
FilterValue can be string, string[], number, number[], boolean, CalendarDate, CalendarDate[], { start, end }, or null.