GitHub47X

Filters

A composable filter system with typed fields, operators, and filter chips.

PreviousNext
      
      <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.
  • outline and secondary variants.
  • sm, md, and lg sizes.

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

ComponentUse
FiltersProviderRoot state and context.
FiltersMenuAdd-filter dropdown.
FiltersMenuTriggerDefault trigger, or custom slot.
FiltersMenuContentField list, groups, and submenus.
FiltersChip wrapper.
FiltersItemOne filter chip.
FiltersClearClears all filters.

FiltersProvider

PropTypeDefault
filtersFilter[][]
fieldsFilterFieldItem[]-
variant"outline" | "secondary""outline"
size"sm" | "md" | "lg""md"

Filters

PropTypeDefault
filterStyle"long" | "short""long"

Fields

Build fields with Field.*.

HelperExtra options
Field.TextFieldminLength, maxLength
Field.NumberFieldmin, max, step, numberFormat
Field.DateFieldmin, max
Field.BooleanFieldtrueValue, falseValue
Field.Grouplabel, fields
Field.Submenulabel, 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.*.

HelperValueInput
Operator.Eqeqinput or select
Operator.Neqneqinput or select
Operator.Containcontainsinput
Operator.NotContainnot_containsinput
Operator.Ininmulti-select
Operator.Ninninmulti-select
Operator.Btwbtwrange
Operator.Nullis_nullnone
Operator.NotNullnot_nullnone
Operator.Gtgtinput
Operator.Ltltinput

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.