Skip to main content
Nexus Design System
GitHub

FilterBuilder

FilterBuilder edits filter conditions inside nested All and Any groups. Pass the tree as value and update it in onValueChange. For a row of applied filters, use FilterCondition or FilterChip.

  • Drafts can be incomplete. Call getFilterErrors to validate text, number, date and choice fields.
  • An empty root means no filters. An empty nested group is invalid.
  • The root is depth zero, and maxDepth defaults to 3.
  • Dates are YYYY-MM-DD strings. Numbers stay as draft strings.
  • Every ID must be unique. Field and option IDs must not be empty.
  • Set operators on a field to limit its operators.
  • Your app owns Apply and Cancel, running the query, URLs, permissions and fetching.
  • See the Filtering pattern for grouped conditions and where the app takes over.
Match
conditions
Match
conditions

Ready to apply

'use client';

import * as React from 'react';

import { Button } from '@/components/nxds/components/button/button';
import { FilterBuilder } from '@/components/nxds/components/filter-builder/filter-builder';
import {
  type FilterField,
  type FilterGroup,
  getFilterErrors,
} from '@/components/nxds/lib/filter-model';

const exampleFields: FilterField[] = [
  {
    id: 'status',
    label: 'Status',
    type: 'choice',
    options: [
      { value: 'active', label: 'Active' },
      { value: 'invited', label: 'Invited' },
      { value: 'suspended', label: 'Suspended' },
    ],
  },
  {
    id: 'team',
    label: 'Team',
    type: 'choice',
    options: [
      { value: 'design', label: 'Design' },
      { value: 'engineering', label: 'Engineering' },
      { value: 'operations', label: 'Operations' },
    ],
  },
  { id: 'name', label: 'Name', type: 'text' },
  { id: 'projects', label: 'Projects', type: 'number' },
  { id: 'joined', label: 'Joined', type: 'date' },
];

const exampleTree: FilterGroup = {
  kind: 'group',
  id: 'root',
  conjunction: 'all',
  children: [
    {
      kind: 'rule',
      id: 'status-rule',
      field: 'status',
      operator: 'is',
      value: 'active',
    },
    {
      kind: 'group',
      id: 'team-group',
      conjunction: 'any',
      children: [
        {
          kind: 'rule',
          id: 'team-rule',
          field: 'team',
          operator: 'is',
          value: 'design',
        },
        {
          kind: 'rule',
          id: 'projects-rule',
          field: 'projects',
          operator: 'greaterThan',
          value: '3',
        },
      ],
    },
  ],
};

function Demo({
  initial = exampleTree,
  disabled = false,
  fields = exampleFields,
  maxDepth = 3,
  onValueChange,
}: {
  initial?: FilterGroup;
  disabled?: boolean;
  fields?: readonly FilterField[];
  maxDepth?: number;
  onValueChange?: (value: FilterGroup) => void;
}) {
  const [value, setValue] = React.useState(initial);
  const errors = getFilterErrors(value, fields, maxDepth);
  function change(next: FilterGroup) {
    setValue(next);
    onValueChange?.(next);
  }
  return (
    <div className="nx:grid nx:gap-4">
      <FilterBuilder
        data-example="controlled"
        fields={fields}
        value={value}
        onValueChange={change}
        disabled={disabled}
        maxDepth={maxDepth}
      />
      <p
        role="status"
        className="nx:typography-body-small nx:text-muted-foreground"
      >
        {errors.length
          ? `${errors.length} incomplete conditions or groups`
          : 'Ready to apply'}
      </p>
      <Button variant="outline" onClick={() => setValue(initial)}>
        Reset example
      </Button>
    </div>
  );
}

export default function FilterBuilderDefault() {
  return (
    <div className="nx:mx-auto nx:w-full nx:max-w-5xl nx:p-4">
      <Demo
        initial={exampleTree}
        fields={exampleFields}
        disabled={false}
        maxDepth={3}
      />
    </div>
  );
}

Installation

npx nxds-cli@latest add filter-builder

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import { FilterBuilder } from '@/components/nxds/components/filter-builder/filter-builder';

Props

FilterBuilder

FilterBuilder props
PropTypeDefaultDescription
aria-labelstring"Filters"

Accessible name for the root group.

disabledbooleanfalseNo description
fieldsRequiredreadonly FilterField[]No default documentedNo description
maxDepthnumber3

Limits how deep new groups go, with the root at zero. Deeper groups already in the tree show as invalid.

onValueChangeRequired(value: FilterGroup) => voidNo default documentedNo description
valueRequiredFilterGroupNo default documentedNo description

FilterBuilder edits filter conditions inside nested All and Any groups. Pass the tree as value and update it on value-change. For a row of applied filters, use FilterCondition or FilterChip.

  • Drafts can be incomplete. Call getFilterErrors to validate text, number, date and choice fields.
  • An empty root means no filters. An empty nested group is invalid.
  • The root is depth zero, and maxDepth defaults to 3.
  • Dates are YYYY-MM-DD strings. Numbers stay as draft strings.
  • Every ID must be unique. Field and option IDs must not be empty.
  • Set operators on a field to limit its operators.
  • Your app owns Apply and Cancel, running the query, URLs, permissions and fetching.
  • See the Filtering pattern for grouped conditions and where the app takes over.
<script setup lang="ts">
import { ref } from 'vue';

import { FilterBuilder } from '@/components/filter-builder';

const fields = [
  {
    id: 'status',
    label: 'Status',
    type: 'choice',
    options: [
      { value: 'active', label: 'Active' },
      { value: 'invited', label: 'Invited' },
      { value: 'suspended', label: 'Suspended' },
    ],
  },
  {
    id: 'team',
    label: 'Team',
    type: 'choice',
    options: [
      { value: 'design', label: 'Design' },
      { value: 'engineering', label: 'Engineering' },
      { value: 'operations', label: 'Operations' },
    ],
  },
  { id: 'name', label: 'Name', type: 'text' },
  { id: 'projects', label: 'Projects', type: 'number' },
  { id: 'joined', label: 'Joined', type: 'date' },
];
const value = ref({
  kind: 'group',
  id: 'root',
  conjunction: 'all',
  children: [
    {
      kind: 'rule',
      id: 'status-rule',
      field: 'status',
      operator: 'is',
      value: 'active',
    },
    {
      kind: 'group',
      id: 'team-group',
      conjunction: 'any',
      children: [
        {
          kind: 'rule',
          id: 'team-rule',
          field: 'team',
          operator: 'is',
          value: 'design',
        },
        {
          kind: 'rule',
          id: 'projects-rule',
          field: 'projects',
          operator: 'greaterThan',
          value: '3',
        },
      ],
    },
  ],
});
</script>

<template>
  <FilterBuilder
    data-example="controlled"
    :fields="fields"
    :value="value"
    @value-change="value = $event"
  />
</template>

Installation

  1. Install these components first:

    Button, ButtonGroup, Checkbox, ChoiceRow, Input, Label, Popover, Select, Spinner.

  2. Copy and paste the following code into your project.

    components/filter-builder/FilterBuilder.vue
    <script lang="ts">
    import type { FilterGroup, FilterRule } from '../../lib/filter-model';
    
    function nodeIds(node: FilterGroup | FilterRule): string[] {
      if (node.kind === 'rule') return [node.id];
      return [node.id, ...node.children.flatMap(nodeIds)];
    }
    
    // Restored trees can already hold IDs from an earlier page load, so skip any in use.
    let nodeCount = 0;
    function createNodeId(prefix: string, taken: ReadonlySet<string>) {
      let id: string;
      do {
        nodeCount += 1;
        id = `${prefix}${nodeCount}`;
      } while (taken.has(id));
      return id;
    }
    </script>
    
    <script setup lang="ts">
    import { computed, type HTMLAttributes, ref, useId } from 'vue';
    
    import { type FilterField, getFilterErrors } from '../../lib/filter-model';
    import { cn } from '../../lib/utils';
    
    import GroupEditor from './GroupEditor.vue';
    
    export interface FilterBuilderProps {
      fields: readonly FilterField[];
      value: FilterGroup;
      disabled?: boolean;
      /** Limits how deep new groups go, with the root at zero. Deeper groups already in the tree show as invalid. */
      maxDepth?: number;
      /** Accessible name for the root group. */
      ariaLabel?: string;
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<FilterBuilderProps>(), {
      disabled: false,
      maxDepth: 3,
      ariaLabel: 'Filters',
    });
    const emit = defineEmits<{ 'value-change': [value: FilterGroup] }>();
    
    const idPrefix = useId();
    const untouched = ref<ReadonlySet<string>>(new Set());
    
    // A newly added rule stays quiet until it is edited; the application still sees its errors.
    const errors = computed(() =>
      getFilterErrors(props.value, props.fields, props.maxDepth).filter(
        (error) => !untouched.value.has(error.id)
      )
    );
    
    function createId(kind: FilterRule['kind'] | FilterGroup['kind']) {
      const id = createNodeId(idPrefix, new Set(nodeIds(props.value)));
      if (kind === 'rule') untouched.value = new Set(untouched.value).add(id);
      return id;
    }
    
    function markEdited(id: string) {
      if (!untouched.value.has(id)) return;
      const next = new Set(untouched.value);
      next.delete(id);
      untouched.value = next;
    }
    </script>
    
    <template>
      <div
        data-slot="filter-builder"
        :data-disabled="disabled || undefined"
        :class="
          cn(
            'nx:@container/builder nx:w-full nx:min-w-0 nx:rounded-lg nx:border-default nx:border-border-default nx:bg-container nx:p-4',
            props.class
          )
        "
      >
        <GroupEditor
          :fields="fields"
          :value="value"
          :disabled="disabled"
          :depth="0"
          :max-depth="maxDepth"
          :errors="errors"
          :label="ariaLabel"
          :create-id="createId"
          @value-change="emit('value-change', $event)"
          @rule-edited="markEdited"
        />
      </div>
    </template>
    
    components/filter-builder/FilterRuleEditor.vue
    <script setup lang="ts">
    import { computed, useId } from 'vue';
    
    import {
      changeFilterOperator,
      emptyFilterRule,
      type FilterField,
      type FilterOperator,
      filterOperatorLabels,
      type FilterRule,
      getFilterOperators,
      isValuelessOperator,
    } from '../../lib/filter-model';
    import {
      IconCalendar,
      IconHash,
      IconLetterCase,
      IconList,
      IconX,
    } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Button } from '../button';
    
    import Picker from './Picker.vue';
    import RuleValue from './RuleValue.vue';
    
    // Edits one controlled condition; numeric and date operands remain draft strings.
    const props = defineProps<{
      value: FilterRule;
      fields: readonly FilterField[];
      disabled?: boolean;
      error?: string;
    }>();
    const emit = defineEmits<{
      'value-change': [value: FilterRule];
      remove: [];
    }>();
    
    const fieldTypeIcons = {
      text: IconLetterCase,
      number: IconHash,
      date: IconCalendar,
      choice: IconList,
    } satisfies Record<FilterField['type'], unknown>;
    
    const errorId = useId();
    const field = computed(() =>
      props.fields.find((item) => item.id === props.value.field)
    );
    const operators = computed(() =>
      field.value ? getFilterOperators(field.value) : []
    );
    const fieldOptions = computed(() =>
      props.fields
        .filter((item) => getFilterOperators(item).length)
        .map((item) => ({ value: item.id, label: item.label }))
    );
    const operatorOptions = computed(() =>
      operators.value.map((operator) => ({
        value: operator,
        label: filterOperatorLabels[operator],
      }))
    );
    
    function changeField(fieldId: string) {
      const nextField = props.fields.find((item) => item.id === fieldId);
      if (!nextField) return;
      const operator = getFilterOperators(nextField)[0];
      if (!operator) return;
      emit('value-change', emptyFilterRule(props.value.id, fieldId, operator));
    }
    
    function changeOperator(operator: FilterOperator) {
      emit('value-change', changeFilterOperator(props.value, operator));
    }
    </script>
    
    <template>
      <div data-slot="filter-rule-editor" class="nx:@container/rule nx:min-w-0">
        <div class="nx:flex nx:min-w-0 nx:items-start nx:gap-1">
          <div
            class="nx:grid nx:min-w-0 nx:flex-1 nx:grid-cols-1 nx:gap-1.5 nx:@sm/rule:grid-cols-2 nx:@lg/rule:gap-0 nx:@lg/rule:grid-cols-[minmax(0,1fr)_max-content_minmax(0,1.3fr)] nx:[&_button:focus-visible]:relative nx:[&_button:focus-visible]:z-10 nx:[&_input:focus-visible]:relative nx:[&_input:focus-visible]:z-10"
          >
            <Picker
              class="nx:w-full nx:bg-container nx:@lg/rule:rounded-e-none"
              label="Field"
              :value="value.field"
              :options="fieldOptions"
              :disabled="disabled"
              :invalid="!field"
              :described-by="error ? errorId : undefined"
              @value-change="changeField"
            >
              <template v-if="field" #leading>
                <component
                  :is="fieldTypeIcons[field.type]"
                  aria-hidden="true"
                  class="nx:size-3.5 nx:shrink-0 nx:text-muted-foreground"
                />
              </template>
            </Picker>
            <Picker
              :class="
                cn(
                  'nx:w-full nx:@lg/rule:rounded-s-none nx:@lg/rule:border-s-0',
                  !isValuelessOperator(value.operator) &&
                    'nx:@lg/rule:rounded-e-none'
                )
              "
              :label="`${field?.label ?? 'Condition'} operator`"
              :value="value.operator"
              :options="operatorOptions"
              :disabled="disabled || !field"
              :invalid="!operators.includes(value.operator)"
              :described-by="error ? errorId : undefined"
              @value-change="changeOperator"
            />
            <div
              v-if="field"
              class="nx:min-w-0 nx:@sm/rule:col-span-2 nx:@lg/rule:col-span-1 nx:@lg/rule:[&_input]:rounded-s-none nx:@lg/rule:[&_input]:border-s-0 nx:@lg/rule:[&_button]:rounded-s-none nx:@lg/rule:[&_button]:border-s-0"
            >
              <RuleValue
                :field="field"
                :rule="value"
                :disabled="disabled"
                :error-id="error ? errorId : undefined"
                :invalid="!!error"
                @value-change="emit('value-change', $event)"
              />
            </div>
          </div>
          <Button
            variant="ghost"
            size="icon-sm"
            class="nx:size-(--nx-spacing-8) nx:shrink-0 nx:text-muted-foreground"
            :aria-label="`Remove ${field?.label ?? 'unavailable'} condition`"
            :disabled="disabled"
            @click="emit('remove')"
          >
            <IconX aria-hidden="true" />
          </Button>
        </div>
        <p
          v-if="error"
          :id="errorId"
          class="nx:mt-1 nx:mb-0 nx:typography-body-small nx:text-error-subtle-foreground"
        >
          {{ error }}
        </p>
      </div>
    </template>
    
    components/filter-builder/GroupEditor.vue
    <script lang="ts">
    import type { FilterErrorCode } from '../../lib/filter-model';
    
    const errorMessages: Record<Exclude<FilterErrorCode, 'tooDeep'>, string> = {
      duplicateId: 'Each condition and group needs a unique ID.',
      unknownField: 'Choose an available field.',
      unknownOperator: 'Choose an available operator.',
      unexpectedValue: 'This operator takes no value.',
      valueShape: 'Choose a value for this operator.',
      incompleteRange: 'Enter both ends of the range.',
      missingValue: 'Enter a value.',
      invalidNumber: 'Enter a valid number.',
      invalidDate: 'Enter a valid date.',
      unknownOption: 'Choose an available option.',
      reversedRange: 'The start must not be after the end.',
      emptyGroup: 'Add a condition or remove this group.',
    };
    function errorMessage(code: FilterErrorCode, maxDepth: number) {
      if (code === 'tooDeep')
        return `Use at most ${maxDepth} levels of nested groups.`;
      return errorMessages[code];
    }
    </script>
    
    <script setup lang="ts">
    import { computed, useTemplateRef } from 'vue';
    
    import {
      emptyFilterRule,
      type FilterError,
      type FilterField,
      type FilterGroup,
      type FilterRule,
      getFilterOperators,
    } from '../../lib/filter-model';
    import { IconPlus, IconX } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    import { Button } from '../button';
    
    import FilterRuleEditor from './FilterRuleEditor.vue';
    import Picker from './Picker.vue';
    
    const props = defineProps<{
      value: FilterGroup;
      fields: readonly FilterField[];
      disabled?: boolean;
      depth: number;
      maxDepth: number;
      errors: FilterError[];
      label: string;
      createId: (kind: FilterRule['kind'] | FilterGroup['kind']) => string;
    }>();
    const emit = defineEmits<{
      'value-change': [value: FilterGroup];
      remove: [];
      'rule-edited': [id: string];
    }>();
    
    const addButton = useTemplateRef<InstanceType<typeof Button>>('add');
    let focusId: string | null = null;
    
    const firstField = computed(() =>
      props.fields.find((field) => getFilterOperators(field).length)
    );
    const groupError = computed(
      () => props.errors.find((error) => error.id === props.value.id)?.code
    );
    
    function newRule(): FilterRule {
      const operator = firstField.value
        ? (getFilterOperators(firstField.value)[0] ?? 'is')
        : 'is';
      return emptyFilterRule(
        props.createId('rule'),
        firstField.value?.id ?? '',
        operator
      );
    }
    
    function addRule() {
      const rule = newRule();
      focusId = rule.id;
      emit('value-change', {
        ...props.value,
        children: [...props.value.children, rule],
      });
    }
    
    function addGroup() {
      const group: FilterGroup = {
        kind: 'group',
        id: props.createId('group'),
        conjunction: 'any',
        children: [newRule()],
      };
      focusId = group.id;
      emit('value-change', {
        ...props.value,
        children: [...props.value.children, group],
      });
    }
    
    function replace(next: FilterGroup | FilterRule) {
      if (next.kind === 'rule') emit('rule-edited', next.id);
      emit('value-change', {
        ...props.value,
        children: props.value.children.map((child) =>
          child.id === next.id ? next : child
        ),
      });
    }
    
    function remove(id: string) {
      emit('value-change', {
        ...props.value,
        children: props.value.children.filter((child) => child.id !== id),
      });
      (addButton.value?.$el as HTMLElement | undefined)?.focus();
    }
    
    function ruleError(id: string) {
      const code = props.errors.find((error) => error.id === id)?.code;
      return code && errorMessage(code, props.maxDepth);
    }
    
    // The node an add just created renders once the new value comes back; focus
    // its first picker then.
    function focusNewNode(node: unknown) {
      if (!(node instanceof HTMLElement) || focusId !== node.dataset.nodeId) return;
      node.querySelector<HTMLButtonElement>('button[role="combobox"]')?.focus();
      focusId = null;
    }
    
    function separator(index: number) {
      if (index === 0) return 'Where';
      return props.value.conjunction === 'all' ? 'And' : 'Or';
    }
    </script>
    
    <template>
      <div
        data-slot="filter-group"
        role="group"
        :aria-label="label"
        :class="
          cn(
            'nx:min-w-0',
            depth > 0 &&
              'nx:rounded-md nx:border-default nx:border-border-default nx:bg-muted-extralight nx:p-3'
          )
        "
      >
        <div class="nx:mb-3 nx:flex nx:items-center nx:gap-2">
          <div
            class="nx:flex nx:min-w-0 nx:flex-1 nx:flex-wrap nx:items-center nx:gap-2"
          >
            <span
              class="nx:shrink-0 nx:@2xl/builder:w-12 nx:typography-body-default nx:text-muted-foreground"
              >Match</span
            >
            <div class="nx:shrink-0">
              <Picker
                class="nx:bg-container"
                :label="`${label} match`"
                :value="value.conjunction"
                :options="[
                  { value: 'all', label: 'All' },
                  { value: 'any', label: 'Any' },
                ]"
                :disabled="disabled"
                @value-change="
                  emit('value-change', { ...value, conjunction: $event })
                "
              />
            </div>
            <span class="nx:typography-body-default nx:text-muted-foreground"
              >conditions</span
            >
          </div>
          <Button
            v-if="depth > 0"
            class="nx:size-(--nx-spacing-8) nx:shrink-0 nx:text-muted-foreground"
            variant="ghost"
            size="icon-sm"
            :aria-label="`Remove ${label}`"
            :disabled="disabled"
            @click="emit('remove')"
          >
            <IconX aria-hidden="true" />
          </Button>
        </div>
        <div class="nx:grid nx:gap-2">
          <div
            v-for="(child, index) in value.children"
            :key="child.id"
            :ref="focusNewNode"
            :data-node-id="child.id"
            class="nx:flex nx:min-w-0 nx:items-start nx:gap-2"
          >
            <span
              aria-hidden="true"
              class="nx:hidden nx:h-(--nx-spacing-8) nx:w-12 nx:shrink-0 nx:@2xl/builder:flex nx:items-center nx:typography-body-default nx:text-muted-foreground"
              >{{ separator(index) }}</span
            >
            <div class="nx:min-w-0 nx:flex-1">
              <GroupEditor
                v-if="child.kind === 'group'"
                :value="child"
                :fields="fields"
                :disabled="disabled"
                :depth="depth + 1"
                :max-depth="maxDepth"
                :errors="errors"
                :label="`${label} group ${index + 1}`"
                :create-id="createId"
                @value-change="replace"
                @remove="remove(child.id)"
                @rule-edited="emit('rule-edited', $event)"
              />
              <FilterRuleEditor
                v-else
                :value="child"
                :fields="fields"
                :disabled="disabled"
                :error="ruleError(child.id)"
                @value-change="replace"
                @remove="remove(child.id)"
              />
            </div>
          </div>
          <p
            v-if="!value.children.length"
            class="nx:m-0 nx:py-2 nx:typography-body-small nx:text-muted-foreground"
          >
            {{
              depth === 0
                ? 'No conditions. All results are included.'
                : 'Add a condition to this group.'
            }}
          </p>
        </div>
        <p
          v-if="groupError"
          class="nx:mt-2 nx:mb-0 nx:typography-body-small nx:text-error-subtle-foreground"
        >
          {{ errorMessage(groupError, maxDepth) }}
        </p>
        <div class="nx:mt-3 nx:flex nx:flex-wrap nx:gap-1">
          <Button
            ref="add"
            size="sm"
            class="nx:h-(--nx-spacing-8) nx:text-muted-foreground"
            variant="ghost"
            :disabled="disabled || !firstField"
            @click="addRule"
          >
            <IconPlus aria-hidden="true" />Add condition</Button
          >
          <Button
            v-if="depth < maxDepth"
            class="nx:h-(--nx-spacing-8) nx:text-muted-foreground"
            variant="ghost"
            :disabled="disabled || !firstField"
            size="sm"
            @click="addGroup"
          >
            <IconPlus aria-hidden="true" />Add group</Button
          >
        </div>
      </div>
    </template>
    
    components/filter-builder/MultipleValues.vue
    <script setup lang="ts">
    import { PopoverRoot, PopoverTrigger } from 'reka-ui';
    import { computed, ref, useId } from 'vue';
    
    import type { FilterField } from '../../lib/filter-model';
    import { IconChevronDown } from '../../lib/icons';
    import { Button } from '../button';
    import { Checkbox } from '../checkbox';
    import { ChoiceRow } from '../choice-row';
    import { Input } from '../input';
    import { PopoverContent } from '../popover';
    
    const props = defineProps<{
      field: Extract<FilterField, { type: 'choice' }>;
      value: string[];
      disabled?: boolean;
      invalid?: boolean;
      describedBy?: string;
    }>();
    const emit = defineEmits<{ 'value-change': [value: string[]] }>();
    
    const id = useId();
    const search = ref('');
    const options = computed(() =>
      props.field.options.filter((option) =>
        option.label.toLocaleLowerCase().includes(search.value.toLocaleLowerCase())
      )
    );
    
    function toggle(option: string) {
      emit(
        'value-change',
        props.value.includes(option)
          ? props.value.filter((item) => item !== option)
          : [...props.value, option]
      );
    }
    
    const summary = computed(() =>
      props.value
        .map(
          (item) =>
            props.field.options.find((option) => option.value === item)?.label ??
            item
        )
        .join(', ')
    );
    </script>
    
    <template>
      <PopoverRoot @update:open="search = ''">
        <PopoverTrigger as-child>
          <Button
            size="sm"
            variant="outline"
            :disabled="disabled"
            :aria-label="`${field.label} values`"
            :aria-invalid="invalid"
            :aria-describedby="describedBy"
            class="nx:h-(--nx-spacing-8) nx:w-full nx:min-w-0 nx:justify-between"
          >
            <span class="nx:truncate">{{ summary || 'Choose values…' }}</span>
            <IconChevronDown aria-hidden="true" />
          </Button>
        </PopoverTrigger>
        <PopoverContent
          align="start"
          :aria-label="`${field.label} values`"
          class="nx:w-64 nx:max-w-(--reka-popover-content-available-width) nx:overflow-hidden nx:p-0"
        >
          <div class="nx:border-b nx:border-border-default nx:p-2">
            <Input
              v-model="search"
              size="sm"
              :aria-label="`Search ${field.label} options`"
              placeholder="Search options…"
              :disabled="disabled"
            />
          </div>
          <div class="nx:max-h-48 nx:overflow-y-auto nx:p-1">
            <ChoiceRow
              v-for="(option, index) in options"
              :key="option.value"
              :for="`${id}-${index}`"
              class="nx:not-has-[:disabled]:hover:bg-popover-hover"
            >
              <Checkbox
                :id="`${id}-${index}`"
                :model-value="value.includes(option.value)"
                :disabled="disabled"
                @update:model-value="toggle(option.value)"
              />
              <span class="nx:min-w-0 nx:break-words">{{ option.label }}</span>
            </ChoiceRow>
            <p
              v-if="!options.length"
              class="nx:m-0 nx:p-2 nx:typography-body-small nx:text-muted-foreground"
            >
              No options found.
            </p>
          </div>
        </PopoverContent>
      </PopoverRoot>
    </template>
    
    components/filter-builder/Picker.vue
    <script setup lang="ts" generic="Value extends string">
    import { computed, type HTMLAttributes, type VNode } from 'vue';
    
    import { cn } from '../../lib/utils';
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '../select';
    
    const props = defineProps<{
      label: string;
      value: Value;
      options: readonly { value: Value; label: string }[];
      disabled?: boolean;
      invalid?: boolean;
      describedBy?: string;
      class?: HTMLAttributes['class'];
    }>();
    const emit = defineEmits<{ 'value-change': [value: Value] }>();
    defineSlots<{ leading?: () => VNode[] }>();
    
    const available = computed(() =>
      props.options.some((option) => option.value === props.value)
    );
    
    function select(next: unknown) {
      const option = props.options.find((item) => item.value === next);
      if (option) emit('value-change', option.value);
    }
    </script>
    
    <template>
      <Select
        :model-value="value"
        :disabled="disabled"
        @update:model-value="select"
      >
        <SelectTrigger
          :aria-label="label"
          :aria-invalid="invalid"
          :aria-describedby="describedBy"
          :class="
            cn(
              'nx:h-(--nx-spacing-8) nx:w-auto nx:min-w-0 nx:max-w-full nx:px-2.5 nx:[&>span]:flex-1 nx:[&>span]:text-start',
              props.class
            )
          "
        >
          <slot name="leading" />
          <SelectValue v-if="!available && value"
            >Unavailable selection</SelectValue
          >
          <SelectValue v-else placeholder="Choose…" />
        </SelectTrigger>
        <SelectContent class="nx:max-w-(--reka-select-content-available-width)">
          <template v-for="option in options" :key="option.value">
            <SelectItem v-if="option.value !== ''" :value="option.value">
              {{ option.label }}
            </SelectItem>
          </template>
        </SelectContent>
      </Select>
    </template>
    
    components/filter-builder/RuleValue.vue
    <script setup lang="ts">
    import type { FilterField, FilterRule } from '../../lib/filter-model';
    import { Input } from '../input';
    
    import MultipleValues from './MultipleValues.vue';
    import Picker from './Picker.vue';
    
    defineProps<{
      field: FilterField;
      rule: FilterRule;
      disabled?: boolean;
      errorId?: string;
      invalid: boolean;
    }>();
    const emit = defineEmits<{ 'value-change': [rule: FilterRule] }>();
    </script>
    
    <template>
      <span
        v-if="rule.operator === 'isEmpty' || rule.operator === 'isNotEmpty'"
        class="nx:self-center nx:typography-body-small nx:text-muted-foreground"
      >
        No value needed
      </span>
      <template
        v-else-if="rule.operator === 'isAnyOf' || rule.operator === 'isNoneOf'"
      >
        <MultipleValues
          v-if="field.type === 'choice'"
          :field="field"
          :value="rule.value"
          :disabled="disabled"
          :invalid="invalid"
          :described-by="errorId"
          @value-change="emit('value-change', { ...rule, value: $event })"
        />
      </template>
      <template v-else-if="rule.operator === 'between'">
        <div
          v-if="field.type !== 'choice'"
          class="nx:flex nx:min-w-0 nx:max-w-full nx:flex-wrap nx:items-center nx:gap-2 nx:@lg/rule:flex-nowrap nx:@lg/rule:gap-0 nx:@lg/rule:[&>input]:flex-1 nx:@lg/rule:[&>input:first-child]:rounded-e-none"
        >
          <Input
            size="sm"
            :type="field.type"
            :step="field.type === 'number' ? 'any' : undefined"
            :aria-label="`${field.label} start`"
            placeholder="From"
            :model-value="rule.value[0]"
            :disabled="disabled"
            :aria-invalid="invalid"
            :aria-describedby="errorId"
            class="nx:min-w-0 nx:w-36 nx:max-w-full nx:typography-body-default nx:h-(--nx-spacing-8)"
            @update:model-value="
              emit('value-change', {
                ...rule,
                value: [String($event), rule.value[1]],
              })
            "
          />
          <Input
            size="sm"
            :type="field.type"
            :step="field.type === 'number' ? 'any' : undefined"
            :aria-label="`${field.label} end`"
            placeholder="To"
            :model-value="rule.value[1]"
            :disabled="disabled"
            :aria-invalid="invalid"
            :aria-describedby="errorId"
            class="nx:min-w-0 nx:w-36 nx:max-w-full nx:typography-body-default nx:h-(--nx-spacing-8)"
            @update:model-value="
              emit('value-change', {
                ...rule,
                value: [rule.value[0], String($event)],
              })
            "
          />
        </div>
      </template>
      <Picker
        v-else-if="field.type === 'choice'"
        class="nx:w-full nx:bg-container"
        :label="`${field.label} value`"
        :options="field.options"
        :value="rule.value"
        :disabled="disabled"
        :invalid="invalid"
        :described-by="errorId"
        @value-change="emit('value-change', { ...rule, value: $event })"
      />
      <Input
        v-else
        size="sm"
        :type="field.type"
        :step="field.type === 'number' ? 'any' : undefined"
        :aria-label="`${field.label} value`"
        :placeholder="field.type === 'date' ? undefined : 'Enter a value…'"
        :model-value="rule.value"
        :disabled="disabled"
        :aria-invalid="invalid"
        :aria-describedby="errorId"
        class="nx:min-w-0 nx:w-full nx:max-w-full nx:bg-container nx:typography-body-default nx:h-(--nx-spacing-8)"
        @update:model-value="
          emit('value-change', { ...rule, value: String($event) })
        "
      />
    </template>
    
    components/filter-builder/index.ts
    export {
      default as FilterBuilder,
      type FilterBuilderProps,
    } from './FilterBuilder.vue';
    
    lib/filter-model.ts
    /**
     * The meaning every evaluator of a rule follows — the blocks' conditions,
     * FilterBuilder trees and the application's own queries:
     *
     * - Text comparisons (`is`, `isNot`, `contains`, `startsWith`) ignore case.
     * - Choice values (`is`, `isNot`, `isAnyOf`, `isNoneOf` on a choice field)
     *   compare option values exactly.
     * - Numbers compare numerically; dates compare as `YYYY-MM-DD` calendar days.
     * - `between` includes both ends. `greaterThan`, `lessThan`, `before` and
     *   `after` exclude the operand.
     * - `isEmpty` matches a record with no value: missing, an empty string or an
     *   empty list. `isNotEmpty` is its negation.
     * - `isNot` and `isNoneOf` are the negations of `is` and `isAnyOf`, so they
     *   also match records with no value.
     */
    export type FilterOperator =
      | 'is'
      | 'isNot'
      | 'contains'
      | 'startsWith'
      | 'greaterThan'
      | 'lessThan'
      | 'between'
      | 'before'
      | 'after'
      | 'isAnyOf'
      | 'isNoneOf'
      | 'isEmpty'
      | 'isNotEmpty';
    
    export interface FilterOption {
      value: string;
      label: string;
    }
    export type FilterField = {
      id: string;
      label: string;
      operators?: readonly FilterOperator[];
    } & (
      | { type: 'text' | 'number' | 'date' }
      | { type: 'choice'; options: readonly FilterOption[] }
    );
    export type ValuelessOperator = 'isEmpty' | 'isNotEmpty';
    type ListOperator = 'isAnyOf' | 'isNoneOf';
    type FilterValue<Operator extends FilterOperator> =
      Operator extends ValuelessOperator
        ? ''
        : Operator extends ListOperator
          ? string[]
          : Operator extends 'between'
            ? [string, string]
            : string;
    /** Numeric and date operands stay strings, so a rule can hold an unfinished draft. */
    export type FilterRule = {
      [Operator in FilterOperator]: {
        kind: 'rule';
        id: string;
        field: string;
        operator: Operator;
        value: FilterValue<Operator>;
      };
    }[FilterOperator];
    export interface FilterGroup {
      kind: 'group';
      id: string;
      conjunction: 'all' | 'any';
      children: (FilterRule | FilterGroup)[];
    }
    export type FilterErrorCode =
      | 'duplicateId'
      | 'unknownField'
      | 'unknownOperator'
      | 'unexpectedValue'
      | 'valueShape'
      | 'incompleteRange'
      | 'missingValue'
      | 'invalidNumber'
      | 'invalidDate'
      | 'unknownOption'
      | 'reversedRange'
      | 'tooDeep'
      | 'emptyGroup';
    export interface FilterError {
      id: string;
      code: FilterErrorCode;
    }
    
    export const filterOperatorLabels: Record<FilterOperator, string> = {
      is: 'is',
      isNot: 'is not',
      contains: 'contains',
      startsWith: 'starts with',
      greaterThan: 'is greater than',
      lessThan: 'is less than',
      between: 'is between',
      before: 'is before',
      after: 'is after',
      isAnyOf: 'is any of',
      isNoneOf: 'is none of',
      isEmpty: 'is empty',
      isNotEmpty: 'is not empty',
    };
    const operatorsByType: Record<FilterField['type'], readonly FilterOperator[]> =
      {
        text: ['contains', 'is', 'isNot', 'startsWith', 'isEmpty', 'isNotEmpty'],
        number: [
          'is',
          'isNot',
          'greaterThan',
          'lessThan',
          'between',
          'isEmpty',
          'isNotEmpty',
        ],
        date: [
          'is',
          'isNot',
          'before',
          'after',
          'between',
          'isEmpty',
          'isNotEmpty',
        ],
        choice: ['is', 'isNot', 'isAnyOf', 'isNoneOf', 'isEmpty', 'isNotEmpty'],
      };
    export function getFilterOperators(
      field: FilterField
    ): readonly FilterOperator[] {
      const compatible = operatorsByType[field.type];
      return field.operators
        ? compatible.filter((operator) => field.operators?.includes(operator))
        : compatible;
    }
    export function isValuelessOperator(
      operator: FilterOperator
    ): operator is ValuelessOperator {
      return operator === 'isEmpty' || operator === 'isNotEmpty';
    }
    function isListOperator(operator: FilterOperator): operator is ListOperator {
      return operator === 'isAnyOf' || operator === 'isNoneOf';
    }
    export function emptyFilterRule(
      id: string,
      field: string,
      operator: FilterOperator
    ): FilterRule {
      const rule = { kind: 'rule', id, field } as const;
      if (isValuelessOperator(operator)) return { ...rule, operator, value: '' };
      if (isListOperator(operator)) return { ...rule, operator, value: [] };
      if (operator === 'between') return { ...rule, operator, value: ['', ''] };
      return { ...rule, operator, value: '' };
    }
    /** Keeps the value when the new operator takes the same shape of value; otherwise starts empty. */
    export function changeFilterOperator(
      rule: FilterRule,
      operator: FilterOperator
    ): FilterRule {
      const next = emptyFilterRule(rule.id, rule.field, operator);
      if (next.operator === 'isEmpty' || next.operator === 'isNotEmpty')
        return next;
      if (next.operator === 'between')
        return rule.operator === 'between' ? { ...next, value: rule.value } : next;
      if (next.operator === 'isAnyOf' || next.operator === 'isNoneOf')
        return rule.operator === 'isAnyOf' || rule.operator === 'isNoneOf'
          ? { ...next, value: rule.value }
          : next;
      return typeof rule.value === 'string' ? { ...next, value: rule.value } : next;
    }
    /** Plain decimal notation; `Number()` alone also accepts `0x10` and `0b1`. */
    const decimalNumber = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
    /** A real `YYYY-MM-DD` calendar day. */
    export function isCalendarDay(value: string) {
      if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000'))
        return false;
      const parsed = new Date(`${value}T00:00:00Z`);
      return (
        !Number.isNaN(parsed.getTime()) &&
        parsed.toISOString().slice(0, 10) === value
      );
    }
    export function getFilterRuleError(
      rule: FilterRule,
      fields: readonly FilterField[]
    ): FilterErrorCode | undefined {
      const field = fields.find((item) => item.id === rule.field);
      if (!field) return 'unknownField';
      if (!getFilterOperators(field).includes(rule.operator))
        return 'unknownOperator';
      if (isValuelessOperator(rule.operator))
        return rule.value === '' ? undefined : 'unexpectedValue';
      const multiple = isListOperator(rule.operator);
      const range = rule.operator === 'between';
      if ((multiple || range) !== Array.isArray(rule.value)) return 'valueShape';
      const values = Array.isArray(rule.value) ? rule.value : [rule.value];
      if (range && values.length !== 2) return 'incompleteRange';
      if (!values.length || values.some((value) => !value.trim()))
        return 'missingValue';
      if (
        field.type === 'number' &&
        values.some(
          (value) =>
            !decimalNumber.test(value.trim()) || !Number.isFinite(Number(value))
        )
      )
        return 'invalidNumber';
      if (field.type === 'date' && values.some((value) => !isCalendarDay(value)))
        return 'invalidDate';
      if (
        field.type === 'choice' &&
        values.some(
          (value) => !field.options.some((option) => option.value === value)
        )
      )
        return 'unknownOption';
      if (range) {
        const [start = '', end = ''] = values;
        if (field.type === 'number' ? Number(start) > Number(end) : start > end)
          return 'reversedRange';
      }
      return undefined;
    }
    /** Root depth is zero. An empty root means no filters; empty nested groups are incomplete. */
    export function getFilterErrors(
      group: FilterGroup,
      fields: readonly FilterField[],
      maxDepth = 3
    ): FilterError[] {
      const errors: FilterError[] = [];
      const ids = new Set<string>();
      function visit(node: FilterGroup | FilterRule, depth: number) {
        if (!node.id || ids.has(node.id))
          errors.push({ id: node.id, code: 'duplicateId' });
        ids.add(node.id);
        if (node.kind === 'rule') {
          const code = getFilterRuleError(node, fields);
          if (code) errors.push({ id: node.id, code });
          return;
        }
        if (depth > maxDepth) errors.push({ id: node.id, code: 'tooDeep' });
        if (depth > 0 && !node.children.length)
          errors.push({ id: node.id, code: 'emptyGroup' });
        for (const child of node.children)
          visit(child, child.kind === 'group' ? depth + 1 : depth);
      }
      visit(group, 0);
      return errors;
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { FilterBuilder } from '@/components/filter-builder';

Props

FilterBuilder

FilterBuilder props
PropTypeDefaultDescription
ariaLabelstring"Filters"

Accessible name for the root group.

disabledbooleanfalseNo description
fieldsRequiredreadonly FilterField[]No default documentedNo description
maxDepthnumber3

Limits how deep new groups go, with the root at zero. Deeper groups already in the tree show as invalid.

valueRequiredFilterGroupNo default documentedNo description