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
getFilterErrorsto 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
maxDepthdefaults to 3. - Dates are
YYYY-MM-DDstrings. Numbers stay as draft strings. - Every ID must be unique. Field and option IDs must not be empty.
- Set
operatorson 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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | "Filters" | Accessible name for the root group. |
disabled | boolean | false | —No description |
fieldsRequired | readonly FilterField[] | —No default documented | —No description |
maxDepth | number | 3 | Limits how deep new groups go, with the root at zero. Deeper groups already in the tree show as invalid. |
onValueChangeRequired | (value: FilterGroup) => void | —No default documented | —No description |
valueRequired | FilterGroup | —No default documented | —No 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
getFilterErrorsto 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
maxDepthdefaults to 3. - Dates are
YYYY-MM-DDstrings. Numbers stay as draft strings. - Every ID must be unique. Field and option IDs must not be empty.
- Set
operatorson 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
- 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
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabel | string | "Filters" | Accessible name for the root group. |
disabled | boolean | false | —No description |
fieldsRequired | readonly FilterField[] | —No default documented | —No description |
maxDepth | number | 3 | Limits how deep new groups go, with the root at zero. Deeper groups already in the tree show as invalid. |
valueRequired | FilterGroup | —No default documented | —No description |